feat: add city stay exploration and resonance

This commit is contained in:
2026-07-24 00:32:46 +09:00
parent e06b15ce7a
commit b7c90c9452
9 changed files with 2034 additions and 34 deletions

233
src/game/data/cityStays.ts Normal file
View File

@@ -0,0 +1,233 @@
import type { EquipmentSlot } from './battleItems';
import type { BattleScenarioId } from './battles';
export type CityStayId = 'xuzhou' | 'xinye' | 'chengdu';
export type CityStayMeta = {
name: string;
region: string;
title: string;
description: string;
};
export type CityInformationVisit = {
id: string;
title: string;
location: string;
description: string;
briefingLines: readonly [string] | readonly [string, string];
itemReward: string;
};
export type CityResonanceDialogueChoice = {
id: string;
label: string;
response: string;
rewardExp: number;
};
export type CityResonanceDialogue = {
id: string;
title: string;
unitIds: readonly [string, string];
bondId: string;
rewardExp: number;
lines: readonly string[];
choices: readonly [CityResonanceDialogueChoice, CityResonanceDialogueChoice];
};
export type CityEquipmentOffer = {
id: string;
itemId: string;
slot: EquipmentSlot;
price: number;
};
export type CityStayDefinition = {
id: CityStayId;
city: CityStayMeta;
afterBattleId: BattleScenarioId;
nextBattleId: BattleScenarioId;
information: CityInformationVisit;
dialogue: CityResonanceDialogue;
equipmentOffers: readonly [CityEquipmentOffer, CityEquipmentOffer, CityEquipmentOffer];
};
export const cityStayDefinitions: readonly CityStayDefinition[] = [
{
id: 'xuzhou',
city: {
name: '서주',
region: '서주성',
title: '서주의 첫 성내 체류',
description: '구원전 직후 성내의 소문과 보급 사정을 살피고, 새로 합류한 미축과 다음 방위선을 준비합니다.'
},
afterBattleId: 'seventh-battle-xuzhou-rescue',
nextBattleId: 'eighth-battle-xiaopei-supply-road',
information: {
id: 'city-xuzhou-xiaopei-ledger',
title: '소패 보급로 장부 확인',
location: '서주 관청',
description: '관청의 창고 장부와 상인들의 증언을 맞추어 소패로 향하는 보급로의 약한 지점을 찾습니다.',
briefingLines: [
'소패의 창고는 서쪽 길목과 마을 어귀를 통해 보급을 받습니다.',
'습격대보다 먼저 마을과 창고 주변을 안정시키면 고순의 압박을 견디기 쉬워집니다.'
],
itemReward: '소패 보급로 장부 1'
},
dialogue: {
id: 'city-xuzhou-liu-mi-supply-trust',
title: '서주의 창고를 맡기다',
unitIds: ['liu-bei', 'mi-zhu'],
bondId: 'liu-bei__mi-zhu',
rewardExp: 12,
lines: [
'미축: 성을 구한 기세만으로는 서주를 지킬 수 없습니다. 상인과 창고지기의 신뢰를 함께 묶어야 합니다.',
'유비: 백성의 곡식을 군의 공으로 돌리지 않겠소. 자중의 장부를 믿고 다음 길을 맡기겠소.'
],
choices: [
{
id: 'city-xuzhou-share-storehouse-duty',
label: '창고와 백성의 몫을 함께 지킨다',
response: '미축은 군량과 구휼 장부를 나누어 적고, 어느 쪽도 희생시키지 않겠다고 답했습니다.',
rewardExp: 10
},
{
id: 'city-xuzhou-trust-mi-zhu-ledger',
label: '보급 판단을 미축에게 맡긴다',
response: '미축은 유비의 신뢰에 고개를 숙이고 소패까지 이어지는 상단과 창고를 직접 정리했습니다.',
rewardExp: 12
}
]
},
equipmentOffers: [
{ id: 'city-xuzhou-iron-spear', itemId: 'iron-spear', slot: 'weapon', price: 620 },
{ id: 'city-xuzhou-lamellar-armor', itemId: 'lamellar-armor', slot: 'armor', price: 680 },
{ id: 'city-xuzhou-grain-pouch', itemId: 'grain-pouch', slot: 'accessory', price: 360 }
]
},
{
id: 'xinye',
city: {
name: '신야',
region: '형주 북부',
title: '신야의 출진 준비',
description: '융중 방문을 마친 뒤 신야의 객사와 장터를 돌며 박망파의 지형을 확인하고 제갈량의 첫 지휘를 준비합니다.'
},
afterBattleId: 'seventeenth-battle-wolong-visit-road',
nextBattleId: 'eighteenth-battle-bowang-ambush',
information: {
id: 'city-xinye-bowang-scout-map',
title: '박망파 숲길 정찰',
location: '신야 객사',
description: '나무꾼과 피난민이 기억하는 숲길을 정찰표와 맞추어 화공 매복에 쓸 수 있는 좁은 길을 찾습니다.',
briefingLines: [
'박망파 중앙 숲길은 적을 깊이 끌어들인 뒤 화공으로 퇴로를 끊기 좋은 지형입니다.',
'서쪽 진영에서 성급히 밀지 말고 조운의 기동과 관우·장비의 길목 차단을 맞추어야 합니다.'
],
itemReward: '박망파 매복 정찰도 1'
},
dialogue: {
id: 'city-xinye-liu-zhuge-first-command',
title: '와룡의 첫 군령',
unitIds: ['liu-bei', 'zhuge-liang'],
bondId: 'liu-bei__zhuge-liang',
rewardExp: 14,
lines: [
'제갈량: 하후돈의 기세를 정면에서 꺾기보다, 그 기세가 스스로 숲 안에 갇히게 해야 합니다.',
'유비: 군사께 첫 지휘를 맡기겠소. 장수들이 계책의 뜻까지 이해하도록 내가 곁에서 힘을 보태겠소.'
],
choices: [
{
id: 'city-xinye-explain-strategy-to-officers',
label: '장수들에게 계책의 뜻을 설명한다',
response: '제갈량은 유비가 장수들의 신뢰를 먼저 묶어 주자, 매복 순서와 퇴로까지 차분히 풀어 설명했습니다.',
rewardExp: 12
},
{
id: 'city-xinye-entrust-first-command',
label: '첫 군령을 온전히 맡긴다',
response: '제갈량은 유비의 결단을 받아 들고 박망파의 바람과 불길을 한 장의 작전도로 정리했습니다.',
rewardExp: 14
}
]
},
equipmentOffers: [
{ id: 'city-xinye-short-bow', itemId: 'short-bow', slot: 'weapon', price: 840 },
{ id: 'city-xinye-lamellar-armor', itemId: 'lamellar-armor', slot: 'armor', price: 960 },
{ id: 'city-xinye-grain-pouch', itemId: 'grain-pouch', slot: 'accessory', price: 520 }
]
},
{
id: 'chengdu',
city: {
name: '성도',
region: '익주',
title: '성도 수습과 북문 준비',
description: '항복 직후 성도의 관청과 시장을 안정시키고 황권에게 가맹관과 한중 방면의 사정을 듣습니다.'
},
afterBattleId: 'thirty-third-battle-chengdu-surrender',
nextBattleId: 'thirty-fourth-battle-jiameng-pass',
information: {
id: 'city-chengdu-jiameng-route-ledger',
title: '가맹관 산길 장부 대조',
location: '성도 임시 관청',
description: '황권이 보관한 북문 장부와 상단의 이동 기록을 대조해 가맹관으로 향하는 산길과 보급 거점을 확인합니다.',
briefingLines: [
'가맹관은 좁은 산길과 보급로가 맞물려 서량 기병의 첫 돌격을 흘려낼 자리가 제한됩니다.',
'황권을 지키며 척후선과 보급로를 안정시키면 마초에게 유비군의 군율을 보여 줄 수 있습니다.'
],
itemReward: '가맹관 산길 장부 1'
},
dialogue: {
id: 'city-chengdu-liu-huang-quan-order',
title: '항복 뒤의 첫 약속',
unitIds: ['liu-bei', 'huang-quan'],
bondId: 'liu-bei__huang-quan',
rewardExp: 16,
lines: [
'황권: 성문은 열렸지만 익주의 마음까지 열린 것은 아닙니다. 북문으로 군을 움직일수록 성도의 질서를 먼저 보여야 합니다.',
'유비: 항복한 이를 의심으로 묶지 않겠소. 공형의 판단으로 성도와 가맹관 사이의 길을 함께 세워 주시오.'
],
choices: [
{
id: 'city-chengdu-preserve-local-order',
label: '익주의 기존 질서를 존중한다',
response: '황권은 성도 관리와 창고지기를 함부로 바꾸지 않겠다는 약속을 듣고 북문 장부를 내놓았습니다.',
rewardExp: 12
},
{
id: 'city-chengdu-appoint-huang-quan-guide',
label: '황권에게 북문 준비를 맡긴다',
response: '황권은 유비의 신뢰를 받아 가맹관의 길과 서량 기병의 움직임을 직접 정리하겠다고 답했습니다.',
rewardExp: 14
}
]
},
equipmentOffers: [
{ id: 'city-chengdu-western-spear', itemId: 'western-cavalry-spear', slot: 'weapon', price: 1880 },
{ id: 'city-chengdu-lamellar-armor', itemId: 'lamellar-armor', slot: 'armor', price: 1640 },
{ id: 'city-chengdu-grain-pouch', itemId: 'grain-pouch', slot: 'accessory', price: 920 }
]
}
];
const cityStayDefinitionById = new Map(cityStayDefinitions.map((definition) => [definition.id, definition]));
const cityStayDefinitionByAfterBattleId = new Map(
cityStayDefinitions.map((definition) => [definition.afterBattleId, definition])
);
const cityStayDefinitionByNextBattleId = new Map(
cityStayDefinitions.map((definition) => [definition.nextBattleId, definition])
);
export function getCityStayDefinition(id: CityStayId): CityStayDefinition {
return cityStayDefinitionById.get(id)!;
}
export function findCityStayAfterBattle(battleId: BattleScenarioId): CityStayDefinition | undefined {
return cityStayDefinitionByAfterBattleId.get(battleId);
}
export function findCityStayBeforeBattle(battleId: BattleScenarioId): CityStayDefinition | undefined {
return cityStayDefinitionByNextBattleId.get(battleId);
}

View File

@@ -29,6 +29,7 @@ import {
resolveCampaignPresentationSoundscape
} from '../data/campaignPresentationProfiles';
import { getSortieFlow } from '../data/campaignFlow';
import { findCityStayBeforeBattle } from '../data/cityStays';
import {
enemyIntentOpeningGuide,
isEnemyIntentCounterplayAvailable,
@@ -17555,9 +17556,39 @@ export class BattleScene extends Phaser.Scene {
return false;
}
private cityInformationDebugState(campaign: CampaignState = getCampaignState()) {
const cityStay = findCityStayBeforeBattle(battleScenario.id);
const completed = Boolean(
cityStay &&
campaign.completedCampVisits.includes(cityStay.information.id)
);
return {
available: Boolean(cityStay),
completed,
cityId: cityStay?.id ?? null,
name: cityStay?.city.name ?? null,
visitId: cityStay?.information.id ?? null,
briefingLines: cityStay ? [...cityStay.information.briefingLines] : []
};
}
private completedCityInformationBriefingLines(campaign: CampaignState = getCampaignState()) {
const cityInformation = this.cityInformationDebugState(campaign);
if (!cityInformation.completed || !cityInformation.name) {
return [];
}
return [
`도시 정보 · ${cityInformation.name}`,
...cityInformation.briefingLines
];
}
private showOpeningBattleEvent() {
const guide = battleScenario.tacticalGuide;
const objectiveLines = guide ? [guide.summary, `진군: ${guide.route}`, guide.focus] : battleScenario.openingObjectiveLines;
const cityInformationLines = this.completedCityInformationBriefingLines();
if (battleScenario.sortie) {
const roleLines = this.sortieOpeningLines();
const title = this.firstPursuitTrinityConfigured() ? '출진 군세 · 도원 삼재진' : '출진 군세';
@@ -17566,6 +17597,7 @@ export class BattleScene extends Phaser.Scene {
...(this.launchSortieRecommendation
? [`선택 전략 · ${this.launchSortieRecommendation.label} · ${this.launchSortieRecommendation.summary}`]
: []),
...cityInformationLines,
enemyIntentOpeningGuide(this.isFirstPursuitRoleEffectBattle() ? tacticalInitiativeThreshold : undefined),
...(battleScenario.id === 'first-battle-zhuo-commandery'
? ['첫 행동 · 아군 선택 → 파란 이동 칸 → 명령 공격 → 붉은 적 선택']
@@ -17574,7 +17606,10 @@ export class BattleScene extends Phaser.Scene {
], { priority: 'critical' });
return;
}
this.triggerBattleEvent('opening', '작전 목표', objectiveLines, { priority: 'critical' });
this.triggerBattleEvent('opening', '작전 목표', [
...objectiveLines,
...cityInformationLines
], { priority: 'critical' });
}
private triggerTacticalEvent(key: BattleTacticalGuideEventKey, fallbackTitle: string, fallbackLines: string[]) {
@@ -17724,7 +17759,11 @@ export class BattleScene extends Phaser.Scene {
}
const maxBodyLines = 3;
const bodyLines = lines.slice(0, maxBodyLines).map((line) => this.truncateBattleLogText(line, 42));
const cityInformationIndex = lines.findIndex((line) => line.startsWith('도시 정보 ·'));
const bannerLines = cityInformationIndex >= maxBodyLines
? [lines[0], ...lines.slice(cityInformationIndex, cityInformationIndex + 2)]
: lines;
const bodyLines = bannerLines.slice(0, maxBodyLines).map((line) => this.truncateBattleLogText(line, 42));
const width = 540;
const height = Phaser.Math.Clamp(80 + bodyLines.length * 22, 108, maxBodyLines > 2 ? 214 : 126);
const left = this.layout.gridX + Math.max(16, (this.layout.gridWidth - width) / 2);
@@ -17871,7 +17910,9 @@ export class BattleScene extends Phaser.Scene {
private showSortieDoctrineBanner(event: BattleSavePendingEvent) {
const { title, lines } = event;
const completedCityInformationLines = new Set(this.completedCityInformationBriefingLines());
const doctrineLines = lines.filter((line) => (
completedCityInformationLines.has(line) ||
line.startsWith('도원 공명') ||
line.startsWith('공명 ') ||
line.startsWith('적 의도') ||
@@ -28677,6 +28718,7 @@ export class BattleScene extends Phaser.Scene {
battleId: battleScenario.id,
battleTitle: battleScenario.title,
environment: this.battleEnvironmentDebugState(),
cityInformation: this.cityInformationDebugState(campaign),
victoryConditionLabel: battleScenario.victoryConditionLabel,
defeatConditionLabel: battleScenario.defeatConditionLabel,
selectedSortieUnitIds: effectiveSortieUnitIds,
@@ -29051,7 +29093,7 @@ export class BattleScene extends Phaser.Scene {
active: Boolean(battleScenario.sortie),
battleId: battleScenario.id,
coveredRoleCount: groups.filter((group) => group.units.length > 0).length,
openingBannerShown: this.triggeredBattleEvents.has('opening'),
openingBannerShown: this.activeBattleEvent?.key === 'opening' || this.triggeredBattleEvents.has('opening'),
roles: groups.map(({ role, definition, units }) => ({
role,
label: definition.label,
@@ -29115,7 +29157,10 @@ export class BattleScene extends Phaser.Scene {
fullCoverage: this.firstPursuitTrinityConfigured(),
trinityActive: this.firstPursuitTrinityActive(),
trinitySupportRange: this.firstPursuitTrinityActive() ? 2 : 1,
openingBannerShown: this.isFirstPursuitRoleEffectBattle() && this.triggeredBattleEvents.has('opening'),
openingBannerShown: this.isFirstPursuitRoleEffectBattle() && (
this.activeBattleEvent?.key === 'opening' ||
this.triggeredBattleEvents.has('opening')
),
roles: this.firstPursuitRoleAssignments().map(({ role, unit, definition }) => ({
unitId: unit.id,
name: unit.name,

View File

@@ -10,6 +10,7 @@ import {
equipmentSlots,
findItemByName,
getItem,
itemInventoryLabel,
type EquipmentSlot,
type ItemDefinition
} from '../data/battleItems';
@@ -23,6 +24,12 @@ import { getUnitClass, terrainRules, type TerrainType, type UnitClassKey } from
import { defaultBattleScenario, getBattleScenario, type BattleScenarioDefinition, type BattleScenarioId } from '../data/battles';
import { getSortieFlow, type SortieFlow } from '../data/campaignFlow';
import { getCampSoundscape, type CampSoundscape } from '../data/campSoundscapes';
import {
findCityStayAfterBattle,
type CityEquipmentOffer,
type CityResonanceDialogueChoice,
type CityStayDefinition
} from '../data/cityStays';
import {
campaignPortraitKeysByUnitId,
portraitAssetEntriesForKey,
@@ -217,7 +224,20 @@ const campNoticeBaseHoldMs = 1800;
const campNoticeCharacterHoldMs = 55;
const campNoticeParagraphPauseMs = 500;
type CampTab = 'status' | 'dialogue' | 'visit' | 'supplies' | 'equipment' | 'progress';
type CampTab = 'status' | 'city' | 'dialogue' | 'visit' | 'supplies' | 'equipment' | 'progress';
type CityStayLocationId = 'information' | 'market' | 'dialogue';
type CityStayLocationView = {
id: CityStayLocationId;
background: Phaser.GameObjects.Rectangle;
};
type CityEquipmentOfferView = {
offerId: string;
itemId: string;
buyButton: Phaser.GameObjects.Rectangle;
};
type CampDialogue = {
id: string;
@@ -11319,6 +11339,12 @@ export class CampScene extends Phaser.Scene {
private campRosterPage = 0;
private campRosterLayout?: CampRosterLayout;
private activeTab: CampTab = 'status';
private selectedCityLocation: CityStayLocationId = 'information';
private cityPanelBackground?: Phaser.GameObjects.Rectangle;
private cityLocationViews: CityStayLocationView[] = [];
private cityInformationActionButton?: Phaser.GameObjects.Rectangle;
private cityDialogueChoiceButtons: Phaser.GameObjects.Rectangle[] = [];
private cityEquipmentOfferViews: CityEquipmentOfferView[] = [];
private selectedDialogueId = campDialogues[0].id;
private selectedVisitId = campVisits[0].id;
private equipmentInventoryPage = 0;
@@ -11432,6 +11458,11 @@ export class CampScene extends Phaser.Scene {
this.contentObjects = [];
this.campRosterPage = 0;
this.campRosterLayout = undefined;
this.cityPanelBackground = undefined;
this.cityLocationViews = [];
this.cityInformationActionButton = undefined;
this.cityDialogueChoiceButtons = [];
this.cityEquipmentOfferViews = [];
this.dialogueObjects = [];
this.sortieObjects = [];
this.sortieComparisonPanelView = undefined;
@@ -11527,6 +11558,19 @@ export class CampScene extends Phaser.Scene {
this.sortieItemAssignments = this.normalizedSortieItemAssignments(this.campaign.sortieItemAssignments);
this.selectedDialogueId = this.availableCampDialogues()[0]?.id ?? campDialogues[0].id;
this.selectedVisitId = this.availableCampVisits()[0]?.id ?? campVisits[0].id;
const cityStay = this.currentCityStay();
if (cityStay) {
const informationComplete = this.completedCampVisits().includes(cityStay.information.id);
const dialogueComplete = this.completedCampDialogues().includes(cityStay.dialogue.id);
this.activeTab = 'city';
this.selectedCityLocation = !informationComplete
? 'information'
: !dialogueComplete
? 'dialogue'
: 'market';
} else if (this.activeTab === 'city') {
this.activeTab = 'status';
}
soundDirector.playSoundscape({
musicKey: this.campSoundscape.music.trackKey,
ambienceKey: this.campSoundscape.ambience.trackKey,
@@ -12495,6 +12539,10 @@ export class CampScene extends Phaser.Scene {
private currentCampTitle() {
const battleId = this.currentCampBattleId();
const cityStay = this.currentCityStay();
if (cityStay) {
return `${cityStay.city.name} 성내 체류`;
}
if (this.campaign?.step === 'shu-han-foundation-camp') {
return '촉한 건국 후 군영';
}
@@ -12709,6 +12757,24 @@ export class CampScene extends Phaser.Scene {
return this.campaign?.latestBattleId ?? this.report?.battleId ?? defaultBattleScenario.id;
}
private currentCityStay() {
if (this.retrySortieBattleId) {
return undefined;
}
return findCityStayAfterBattle(this.currentCampBattleId() as BattleScenarioId);
}
private hasPendingCityStayActions() {
const cityStay = this.currentCityStay();
return Boolean(
cityStay &&
(
!this.completedCampVisits().includes(cityStay.information.id) ||
!this.completedCampDialogues().includes(cityStay.dialogue.id)
)
);
}
private availableCampDialogues() {
const battleId = this.currentCampBattleId();
const battleDialogues = campDialogues.filter((dialogue) => dialogue.availableAfterBattleIds.includes(battleId));
@@ -12741,12 +12807,22 @@ export class CampScene extends Phaser.Scene {
}
private renderStaticButtons() {
this.addTabButton('장수', 'status', 576, 38);
this.addTabButton('대화', 'dialogue', 650, 38);
this.addTabButton('방문', 'visit', 724, 38);
this.addTabButton('보급', 'supplies', 798, 38);
this.addTabButton('장비', 'equipment', 872, 38);
this.addTabButton('연표', 'progress', 946, 38);
if (this.currentCityStay()) {
this.addTabButton('장수', 'status', 566, 38, 60, 14);
this.addTabButton('성내', 'city', 630, 38, 60, 14);
this.addTabButton('대화', 'dialogue', 694, 38, 60, 14);
this.addTabButton('방문', 'visit', 758, 38, 60, 14);
this.addTabButton('보급', 'supplies', 822, 38, 60, 14);
this.addTabButton('장비', 'equipment', 886, 38, 60, 14);
this.addTabButton('연표', 'progress', 950, 38, 60, 14);
} else {
this.addTabButton('장수', 'status', 576, 38);
this.addTabButton('대화', 'dialogue', 650, 38);
this.addTabButton('방문', 'visit', 724, 38);
this.addTabButton('보급', 'supplies', 798, 38);
this.addTabButton('장비', 'equipment', 872, 38);
this.addTabButton('연표', 'progress', 946, 38);
}
this.addCommandButton('저장', 1030, 38, 76, () => {
soundDirector.playSelect();
this.showCampSaveSlotPanel();
@@ -13638,8 +13714,8 @@ export class CampScene extends Phaser.Scene {
this.startVictoryStory();
}
private addTabButton(label: string, tab: CampTab, x: number, y: number) {
const bg = this.scaleLegacyCampUi(this.add.rectangle(x, y, 76, 34, 0x182431, 0.94));
private addTabButton(label: string, tab: CampTab, x: number, y: number, width = 76, fontSize = 16) {
const bg = this.scaleLegacyCampUi(this.add.rectangle(x, y, width, 34, 0x182431, 0.94));
bg.setStrokeStyle(1, palette.blue, 0.62);
bg.setInteractive({ useHandCursor: true });
@@ -13654,14 +13730,15 @@ export class CampScene extends Phaser.Scene {
const text = this.scaleLegacyCampUi(this.add.text(x, y, label, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '16px',
fontSize: `${fontSize}px`,
color: '#f2e3bf',
fontStyle: '700'
}));
text.setOrigin(0.5);
text.setInteractive({ useHandCursor: true });
const newBadge = tab === 'supplies' || tab === 'equipment'
? this.scaleLegacyCampUi(this.add.text(x + 29, y - 17, 'NEW', {
const badgeLabel = tab === 'city' ? '새' : width < 70 ? 'N' : 'NEW';
const newBadge = tab === 'city' || tab === 'supplies' || tab === 'equipment'
? this.scaleLegacyCampUi(this.add.text(x + width / 2 - 10, y - 17, badgeLabel, {
...this.textStyle(9, '#fff2b8', true),
backgroundColor: '#8a3f25',
padding: { x: 4, y: 2 }
@@ -13693,6 +13770,7 @@ export class CampScene extends Phaser.Scene {
indicator.setVisible(active || hovered);
indicator.setAlpha(active ? 1 : 0.66);
newBadge?.setVisible(Boolean(
(tab === 'city' && this.hasPendingCityStayActions()) ||
(tab === 'supplies' && pendingCategories.has('supplies')) ||
(tab === 'equipment' && pendingCategories.has('equipment'))
));
@@ -13741,6 +13819,11 @@ export class CampScene extends Phaser.Scene {
this.renderUnitColumn();
this.renderReportPanel();
if (this.activeTab === 'city') {
this.renderCityStayPanel();
return;
}
if (this.activeTab === 'dialogue') {
this.renderDialoguePanel();
return;
@@ -19899,7 +19982,7 @@ export class CampScene extends Phaser.Scene {
return;
}
if (flow.nextBattleId === campBattleIds.seventeenth && this.wolongClueCount() <= 0) {
if (flow.nextBattleId === campBattleIds.seventeenth && !this.hasWolongAudienceLead()) {
this.hideSortiePrep();
this.activeTab = 'visit';
this.render();
@@ -22318,8 +22401,8 @@ export class CampScene extends Phaser.Scene {
const dialogues = this.availableCampDialogues();
const compactDialogueList = dialogues.length > 3;
const dialogueRowGap = compactDialogueList ? 44 : 64;
const dialogueRowHeight = compactDialogueList ? 36 : 48;
const dialogueRowGap = compactDialogueList ? 35 : 64;
const dialogueRowHeight = compactDialogueList ? 30 : 48;
if (!dialogues.some((dialogue) => dialogue.id === this.selectedDialogueId)) {
this.selectedDialogueId = dialogues[0]?.id ?? campDialogues[0].id;
}
@@ -22337,9 +22420,9 @@ export class CampScene extends Phaser.Scene {
this.selectedDialogueId = dialogue.id;
this.render();
});
this.track(this.add.text(x + 38, rowY + (compactDialogueList ? 5 : 8), dialogue.title, this.textStyle(compactDialogueList ? 13 : 15, completed ? '#a8ffd0' : '#f2e3bf', true)));
this.track(this.add.text(x + 38, rowY + (compactDialogueList ? 3 : 8), dialogue.title, this.textStyle(compactDialogueList ? 11 : 15, completed ? '#a8ffd0' : '#f2e3bf', true)));
const maxReward = dialogue.rewardExp + Math.max(...dialogue.choices.map((choice) => choice.rewardExp));
this.track(this.add.text(x + 38, rowY + (compactDialogueList ? 22 : 29), `${this.unitName(dialogue.unitIds[0])} · ${this.unitName(dialogue.unitIds[1])} 공명 +${dialogue.rewardExp}~${maxReward}`, this.textStyle(compactDialogueList ? 10 : 12, '#9fb0bf')));
this.track(this.add.text(x + 38, rowY + (compactDialogueList ? 17 : 29), `${this.unitName(dialogue.unitIds[0])} · ${this.unitName(dialogue.unitIds[1])} 공명 +${dialogue.rewardExp}~${maxReward}`, this.textStyle(compactDialogueList ? 9 : 12, '#9fb0bf')));
});
this.renderSelectedDialogue(x + 372, y + 96, 416, 300);
@@ -22409,10 +22492,20 @@ export class CampScene extends Phaser.Scene {
}
private renderBondList(x: number, y: number, width: number) {
const bonds = this.currentBonds();
const dialogues = this.availableCampDialogues();
const relatedBondIds = new Set(dialogues.map((dialogue) => dialogue.bondId));
const relatedBonds = this.currentBonds().filter((bond) => relatedBondIds.has(bond.id));
const selectedBondId = dialogues.find((dialogue) => dialogue.id === this.selectedDialogueId)?.bondId;
const selectedBond = relatedBonds.find((bond) => bond.id === selectedBondId);
const orderedBonds = selectedBond
? [selectedBond, ...relatedBonds.filter((bond) => bond.id !== selectedBond.id)]
: relatedBonds.length > 0
? relatedBonds
: this.currentBonds();
const bonds = orderedBonds.slice(0, 4);
const compact = bonds.length > 3;
const rowGap = compact ? 25 : 36;
this.track(this.add.text(x, y, '공명', this.textStyle(18, '#f2e3bf', true)));
this.track(this.add.text(x, y, relatedBonds.length > 0 ? '공명 · 현재 대화' : '공명', this.textStyle(18, '#f2e3bf', true)));
bonds.forEach((bond, index) => {
const rowY = y + (compact ? 28 : 32) + index * rowGap;
const first = this.unitName(bond.unitIds[0]);
@@ -22422,13 +22515,409 @@ export class CampScene extends Phaser.Scene {
});
}
private renderCityStayPanel() {
const cityStay = this.currentCityStay();
if (!cityStay) {
this.activeTab = 'status';
this.render();
return;
}
const x = 394;
const y = 120;
const width = 828;
const height = 444;
const informationComplete = this.completedCampVisits().includes(cityStay.information.id);
const dialogueComplete = this.completedCampDialogues().includes(cityStay.dialogue.id);
const bg = this.track(this.add.rectangle(x, y, width, height, 0x101820, 0.92));
this.cityPanelBackground = bg;
bg.setOrigin(0);
bg.setStrokeStyle(1, this.campSkinSelection?.skin.accentColor ?? palette.gold, 0.68);
this.track(this.add.text(x + 24, y + 18, cityStay.city.title, this.textStyle(24, '#f2e3bf', true)));
this.track(this.add.text(x + 24, y + 51, cityStay.city.description, {
...this.textStyle(13, '#d4dce6'),
wordWrap: { width: 600, useAdvancedWrap: true }
}));
const progress = this.track(this.add.text(
x + width - 24,
y + 23,
`체류 기록 ${Number(informationComplete) + Number(dialogueComplete)}/2`,
this.textStyle(13, informationComplete && dialogueComplete ? '#a8ffd0' : '#d8b15f', true)
));
progress.setOrigin(1, 0);
const locations: Array<{
id: CityStayLocationId;
mark: string;
title: string;
subtitle: string;
status: string;
complete: boolean;
}> = [
{
id: 'information',
mark: '첩',
title: '정보 수집',
subtitle: cityStay.information.location,
status: informationComplete ? '다음 전투 정보 확보' : '새 정보 확인 가능',
complete: informationComplete
},
{
id: 'market',
mark: '시',
title: '시장과 대장간',
subtitle: `${cityStay.equipmentOffers.length}종 장비`,
status: `군자금 ${this.campaign?.gold ?? 0}`,
complete: false
},
{
id: 'dialogue',
mark: '공',
title: '동료와 대화',
subtitle: this.cityDialogueUnitNames(cityStay),
status: dialogueComplete ? '공명 대화 완료' : '선택에 따라 공명 상승',
complete: dialogueComplete
}
];
this.cityLocationViews = locations.map((location, index) => {
const cardX = x + 24 + index * 266;
const cardY = y + 86;
const selected = this.selectedCityLocation === location.id;
const card = this.track(this.add.rectangle(
cardX,
cardY,
248,
70,
selected ? 0x283d50 : 0x151f2a,
selected ? 0.98 : 0.9
));
card.setOrigin(0);
card.setStrokeStyle(
selected ? 2 : 1,
location.complete ? palette.green : selected ? palette.gold : palette.blue,
selected ? 0.88 : 0.5
);
card.setInteractive({ useHandCursor: true });
card.on('pointerover', () => {
if (this.selectedCityLocation !== location.id) {
card.setFillStyle(0x213343, 0.96);
}
});
card.on('pointerout', () => {
if (this.selectedCityLocation !== location.id) {
card.setFillStyle(0x151f2a, 0.9);
}
});
card.on('pointerdown', () => {
soundDirector.playSelect();
this.selectedCityLocation = location.id;
this.render();
});
const mark = this.track(this.add.circle(cardX + 29, cardY + 35, 18, 0x0d141c, 0.96));
mark.setStrokeStyle(1, location.complete ? palette.green : palette.gold, 0.72);
const markText = this.track(this.add.text(cardX + 29, cardY + 35, location.mark, this.textStyle(15, '#f2e3bf', true)));
markText.setOrigin(0.5);
this.track(this.add.text(cardX + 56, cardY + 10, location.title, this.textStyle(15, '#f2e3bf', true)));
this.track(this.add.text(cardX + 56, cardY + 31, this.compactText(location.subtitle, 20), this.textStyle(11, '#9fb0bf')));
this.track(this.add.text(
cardX + 56,
cardY + 49,
location.status,
this.textStyle(10, location.complete ? '#a8ffd0' : selected ? '#e8c978' : '#d4dce6', true)
));
return { id: location.id, background: card };
});
const detailX = x + 24;
const detailY = y + 172;
const detailWidth = width - 48;
const detailHeight = 248;
if (this.selectedCityLocation === 'market') {
this.renderCityMarket(cityStay, detailX, detailY, detailWidth, detailHeight);
return;
}
if (this.selectedCityLocation === 'dialogue') {
this.renderCityDialogue(cityStay, detailX, detailY, detailWidth, detailHeight);
return;
}
this.renderCityInformation(cityStay, detailX, detailY, detailWidth, detailHeight);
}
private renderCityInformation(
cityStay: CityStayDefinition,
x: number,
y: number,
width: number,
height: number
) {
const information = cityStay.information;
const completed = this.completedCampVisits().includes(information.id);
const bg = this.track(this.add.rectangle(x, y, width, height, 0x0d141c, 0.94));
bg.setOrigin(0);
bg.setStrokeStyle(1, completed ? palette.green : palette.gold, 0.54);
this.track(this.add.text(x + 20, y + 14, information.title, this.textStyle(19, '#f2e3bf', true)));
this.track(this.add.text(x + 20, y + 43, information.description, {
...this.textStyle(13, '#d4dce6'),
wordWrap: { width: width - 40, useAdvancedWrap: true },
lineSpacing: 2
}));
if (completed) {
const reveal = this.track(this.add.rectangle(x + 20, y + 91, width - 40, 102, 0x17231d, 0.96));
reveal.setOrigin(0);
reveal.setStrokeStyle(1, palette.green, 0.68);
this.track(this.add.text(x + 34, y + 103, `확보한 정보 · ${cityStay.city.name}`, this.textStyle(13, '#a8ffd0', true)));
this.track(this.add.text(x + 34, y + 128, information.briefingLines.join('\n'), {
...this.textStyle(12, '#d4dce6'),
wordWrap: { width: width - 68, useAdvancedWrap: true },
lineSpacing: 4
}));
this.track(this.add.text(
x + 20,
y + height - 29,
`다음 전투 브리핑에 반영 · 기록 ${information.itemReward}`,
this.textStyle(12, '#d8b15f', true)
));
return;
}
this.track(this.add.text(
x + 20,
y + 103,
'관청 문서와 현지 증언을 대조하면 다음 전투의 지형과 적 움직임이 브리핑에 추가됩니다.',
{
...this.textStyle(13, '#9fb0bf'),
wordWrap: { width: width - 40, useAdvancedWrap: true }
}
));
const action = this.track(this.add.rectangle(x + width / 2, y + height - 36, 260, 38, 0x4a371d, 0.98));
this.cityInformationActionButton = action;
action.setStrokeStyle(2, palette.gold, 0.92);
action.setInteractive({ useHandCursor: true });
action.on('pointerover', () => action.setFillStyle(0x654c25, 0.99));
action.on('pointerout', () => action.setFillStyle(0x4a371d, 0.98));
action.on('pointerdown', () => this.gatherCityInformation(cityStay));
const label = this.track(this.add.text(
x + width / 2,
y + height - 36,
'성내 정보 수집',
this.textStyle(14, '#fff2b8', true)
));
label.setOrigin(0.5);
}
private renderCityMarket(
cityStay: CityStayDefinition,
x: number,
y: number,
width: number,
height: number
) {
const campaign = this.campaign ?? getCampaignState();
const bg = this.track(this.add.rectangle(x, y, width, height, 0x0d141c, 0.94));
bg.setOrigin(0);
bg.setStrokeStyle(1, palette.gold, 0.52);
this.track(this.add.text(x + 20, y + 14, `${cityStay.city.name} 시장 · 군자금 ${campaign.gold}`, this.textStyle(19, '#f2e3bf', true)));
this.track(this.add.text(x + width - 20, y + 18, '일반 장비 · 구매 즉시 장비 탭에서 사용', this.textStyle(11, '#9fb0bf', true))).setOrigin(1, 0);
this.cityEquipmentOfferViews = [];
cityStay.equipmentOffers.forEach((offer, index) => {
const item = getItem(offer.itemId);
const rowY = y + 50 + index * 60;
const canBuy = campaign.gold >= offer.price;
const owned = campaign.inventory[itemInventoryLabel(item.id)] ?? 0;
const row = this.track(this.add.rectangle(x + 20, rowY, width - 40, 48, canBuy ? 0x151f2a : 0x111820, canBuy ? 0.94 : 0.76));
row.setOrigin(0);
row.setStrokeStyle(1, canBuy ? palette.blue : 0x53606c, canBuy ? 0.52 : 0.3);
const iconKey = `item-${item.id}-micro`;
if (this.textures.exists(iconKey)) {
const iconFrame = this.track(this.add.rectangle(x + 42, rowY + 24, 34, 34, 0x0a1017, 0.94));
iconFrame.setStrokeStyle(1, palette.gold, 0.54);
const icon = this.track(this.add.image(x + 42, rowY + 24, iconKey));
icon.setDisplaySize(this.campUiLength(25), this.campUiLength(25));
}
this.track(this.add.text(x + 68, rowY + 7, `${item.name} · ${equipmentSlotLabels[item.slot]}`, this.textStyle(14, canBuy ? '#f2e3bf' : '#7f8994', true)));
this.track(this.add.text(
x + 68,
rowY + 27,
`${this.itemBonusText(item)} · 보유 ${owned}`,
this.textStyle(11, canBuy ? '#9fb0bf' : '#68737d')
));
this.track(this.add.text(x + width - 156, rowY + 16, `${offer.price}`, this.textStyle(13, canBuy ? '#d8b15f' : '#7f8994', true)));
const button = this.track(this.add.rectangle(x + width - 66, rowY + 24, 72, 30, canBuy ? 0x1a2630 : 0x121922, canBuy ? 0.98 : 0.72));
button.setStrokeStyle(1, canBuy ? palette.gold : 0x53606c, canBuy ? 0.78 : 0.36);
const buttonText = this.track(this.add.text(
x + width - 66,
rowY + 24,
canBuy ? '구매' : '부족',
this.textStyle(12, canBuy ? '#fff2b8' : '#7f8994', true)
));
buttonText.setOrigin(0.5);
if (canBuy) {
button.setInteractive({ useHandCursor: true });
button.on('pointerover', () => button.setFillStyle(0x283947, 0.99));
button.on('pointerout', () => button.setFillStyle(0x1a2630, 0.98));
button.on('pointerdown', () => this.buyCityEquipment(offer));
}
this.cityEquipmentOfferViews.push({ offerId: offer.id, itemId: offer.itemId, buyButton: button });
});
}
private renderCityDialogue(
cityStay: CityStayDefinition,
x: number,
y: number,
width: number,
height: number
) {
const dialogue = cityStay.dialogue;
const completed = this.completedCampDialogues().includes(dialogue.id);
const bg = this.track(this.add.rectangle(x, y, width, height, 0x0d141c, 0.94));
bg.setOrigin(0);
bg.setStrokeStyle(1, completed ? palette.green : palette.gold, 0.54);
this.track(this.add.text(x + 20, y + 14, dialogue.title, this.textStyle(19, '#f2e3bf', true)));
const people = this.track(this.add.text(x + width - 20, y + 18, this.cityDialogueUnitNames(cityStay), this.textStyle(12, '#d8b15f', true)));
people.setOrigin(1, 0);
this.track(this.add.text(x + 20, y + 48, dialogue.lines.join('\n'), {
...this.textStyle(12, '#d4dce6'),
wordWrap: { width: width - 40, useAdvancedWrap: true },
lineSpacing: 4
}));
if (completed) {
const choiceId = this.campaign?.campDialogueChoiceIds[dialogue.id];
const choice = dialogue.choices.find((candidate) => candidate.id === choiceId);
const memory = this.track(this.add.rectangle(x + 20, y + height - 80, width - 40, 60, 0x17231d, 0.96));
memory.setOrigin(0);
memory.setStrokeStyle(1, palette.green, 0.7);
this.track(this.add.text(
x + 34,
y + height - 69,
choice ? `나의 결정 · ${choice.label}` : '공명 대화 완료',
this.textStyle(12, '#a8ffd0', true)
));
this.track(this.add.text(
x + 34,
y + height - 45,
choice ? this.compactText(choice.response, 88) : '이 선택은 저장되어 다시 보상을 받을 수 없습니다.',
{
...this.textStyle(11, '#d4dce6'),
wordWrap: { width: width - 68, useAdvancedWrap: true }
}
));
return;
}
this.cityDialogueChoiceButtons = dialogue.choices.map((choice, index) => {
const buttonWidth = (width - 54) / 2;
const buttonX = x + 20 + buttonWidth / 2 + index * (buttonWidth + 14);
const buttonY = y + height - 37;
const button = this.track(this.add.rectangle(buttonX, buttonY, buttonWidth, 42, 0x1a2630, 0.98));
button.setStrokeStyle(1, palette.gold, 0.78);
button.setInteractive({ useHandCursor: true });
button.on('pointerover', () => button.setFillStyle(0x283947, 0.99));
button.on('pointerout', () => button.setFillStyle(0x1a2630, 0.98));
button.on('pointerdown', () => this.completeCityDialogue(cityStay, choice));
const label = this.track(this.add.text(
buttonX,
buttonY,
`${choice.label} · 공명 +${dialogue.rewardExp + choice.rewardExp}`,
this.textStyle(12, '#fff2b8', true)
));
label.setOrigin(0.5);
return button;
});
}
private cityDialogueUnitNames(cityStay: CityStayDefinition) {
const names = cityStay.dialogue.unitIds.map((unitId) => (
this.currentUnits().find((unit) => unit.id === unitId)?.name ?? unitId
));
return names.join(' · ');
}
private gatherCityInformation(cityStay: CityStayDefinition) {
if (this.completedCampVisits().includes(cityStay.information.id)) {
this.showCampNotice('이미 확보한 정보입니다.');
return;
}
const updated = applyCampVisitReward(
cityStay.information.id,
{ itemRewards: [cityStay.information.itemReward] },
'city-information-gathered'
);
if (!updated) {
this.showCampNotice('정보를 장부에 기록하지 못했습니다. 전투 결과 저장 상태를 확인해 주세요.');
return;
}
this.campaign = updated;
this.report = updated.firstBattleReport ?? this.report;
soundDirector.playEffect('exp-gain', { volume: 0.24, stopAfterMs: 700 });
this.showCampNotice(
`정보 확보 · ${cityStay.information.title}\n다음 전투 브리핑에 ${cityStay.city.name} 현지 정보가 추가됩니다.`
);
this.render();
}
private completeCityDialogue(cityStay: CityStayDefinition, choice: CityResonanceDialogueChoice) {
const dialogue = cityStay.dialogue;
if (this.completedCampDialogues().includes(dialogue.id)) {
this.showCampNotice('이미 완료된 공명 대화입니다.');
return;
}
const rewardExp = dialogue.rewardExp + choice.rewardExp;
const updated = applyCampBondExp(dialogue.id, dialogue.bondId, rewardExp, choice.id);
if (!updated) {
this.showCampNotice('두 동료의 공명 관계를 찾지 못했습니다. 합류 상태를 확인해 주세요.');
return;
}
this.report = updated;
this.campaign = getCampaignState();
soundDirector.playEffect('exp-gain', { volume: 0.28, stopAfterMs: 700 });
this.showCampNotice(`공명 +${rewardExp} · ${choice.label}\n${choice.response}`);
this.render();
}
private buyCityEquipment(offer: CityEquipmentOffer) {
const cityStay = this.currentCityStay();
if (!cityStay || !cityStay.equipmentOffers.some((candidate) => candidate.id === offer.id)) {
this.showCampNotice('현재 시장에서는 살 수 없는 장비입니다.');
return;
}
const item = getItem(offer.itemId);
const campaign = this.campaign ?? getCampaignState();
if (item.rank !== 'common') {
this.showCampNotice('보물 장비는 일반 시장에서 거래할 수 없습니다.');
return;
}
if (campaign.gold < offer.price) {
this.showCampNotice('군자금이 부족합니다.');
return;
}
campaign.gold -= offer.price;
const label = itemInventoryLabel(item.id);
campaign.inventory[label] = (campaign.inventory[label] ?? 0) + 1;
this.campaign = saveCampaignState(campaign);
this.report = this.campaign.firstBattleReport ?? this.report;
soundDirector.playSelect();
this.showCampNotice(`${cityStay.city.name} 시장 · ${item.name} 구입 · 군자금 -${offer.price}`);
this.render();
}
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)));
const locationLabel = this.currentCityStay()?.city.name ?? this.campSkinSelection?.skin.locationLabel ?? '현지';
this.track(this.add.text(x + 24, y + 22, `${locationLabel} 방문`, this.textStyle(24, '#f2e3bf', true)));
this.track(this.add.text(x + 24, y + 56, '군영 밖 사람들을 만나 정보, 보급, 공명 보상을 얻습니다. 각 방문은 한 번만 완료할 수 있습니다.', this.textStyle(14, '#d4dce6')));
const visits = this.availableCampVisits();
@@ -22445,11 +22934,14 @@ export class CampScene extends Phaser.Scene {
return;
}
const compactVisitList = visits.length > 3;
const visitRowGap = compactVisitList ? 54 : 66;
const visitRowHeight = compactVisitList ? 42 : 50;
visits.forEach((visit, index) => {
const completed = this.completedCampVisits().includes(visit.id);
const rowY = y + 96 + index * 66;
const rowY = y + 96 + index * visitRowGap;
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));
const row = this.track(this.add.rectangle(x + 24, rowY, 320, visitRowHeight, 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 });
@@ -22458,8 +22950,8 @@ export class CampScene extends Phaser.Scene {
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.track(this.add.text(x + 38, rowY + (compactVisitList ? 5 : 8), visit.title, this.textStyle(compactVisitList ? 13 : 15, completed ? '#a8ffd0' : '#f2e3bf', true)));
this.track(this.add.text(x + 38, rowY + (compactVisitList ? 23 : 29), visit.location, this.textStyle(compactVisitList ? 10 : 12, '#9fb0bf')));
});
this.renderSelectedVisit(x + 372, y + 96, 416, 300);
@@ -22537,8 +23029,11 @@ export class CampScene extends Phaser.Scene {
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.wolongClueCount();
this.track(this.add.text(x + 14, y + 66, `제갈량 단서 ${insightCount} · 군자금 ${this.campaign?.gold ?? 0}`, this.textStyle(13, '#9fb0bf', true)));
const isWolongSearch = this.currentSortieFlow().nextBattleId === campBattleIds.seventeenth;
const informationLabel = isWolongSearch
? `와룡 단서 ${this.hasWolongAudienceLead() ? 1 : 0}/1`
: `현지 기록 ${completed.length}`;
this.track(this.add.text(x + 14, y + 66, `${informationLabel} · 군자금 ${this.campaign?.gold ?? 0}`, this.textStyle(13, '#9fb0bf', true)));
}
private renderEquipmentPanel() {
@@ -23931,6 +24426,15 @@ export class CampScene extends Phaser.Scene {
return this.inventoryAmount('와룡 소문') + this.inventoryAmount('민심 기록');
}
private hasWolongAudienceLead() {
const completedVisits = this.completedCampVisits();
return (
this.wolongClueCount() > 0 ||
completedVisits.includes('jingzhou-scholar-rumors') ||
completedVisits.includes('refugee-village-patrol')
);
}
private drawBar(x: number, y: number, width: number, height: number, ratio: number, color: number) {
const track = this.track(this.add.rectangle(x, y, width, height, 0x070b10, 0.86));
track.setOrigin(0);
@@ -24025,6 +24529,11 @@ export class CampScene extends Phaser.Scene {
this.contentObjects.forEach((object) => object.destroy());
this.contentObjects = [];
this.campRosterLayout = undefined;
this.cityPanelBackground = undefined;
this.cityLocationViews = [];
this.cityInformationActionButton = undefined;
this.cityDialogueChoiceButtons = [];
this.cityEquipmentOfferViews = [];
this.equipmentPanelBackground = undefined;
this.equipmentInventoryPreviousButton = undefined;
this.equipmentInventoryNextButton = undefined;
@@ -24164,6 +24673,13 @@ export class CampScene extends Phaser.Scene {
const equipmentSwapCandidateItem = this.pendingEquipmentSwap
? getItem(this.pendingEquipmentSwap.candidateItemId)
: undefined;
const cityStay = this.currentCityStay();
const cityInformationComplete = cityStay
? this.completedCampVisits().includes(cityStay.information.id)
: false;
const cityDialogueComplete = cityStay
? this.completedCampDialogues().includes(cityStay.dialogue.id)
: false;
return {
scene: this.scene.key,
victoryRewardAcknowledgement: {
@@ -24220,6 +24736,76 @@ export class CampScene extends Phaser.Scene {
strokeColor: button.bg.strokeColor,
lineWidth: button.bg.lineWidth
})),
cityStay: cityStay
? {
available: true,
id: cityStay.id,
name: cityStay.city.name,
region: cityStay.city.region,
afterBattleId: cityStay.afterBattleId,
nextBattleId: cityStay.nextBattleId,
active: this.activeTab === 'city',
selectedLocation: this.selectedCityLocation,
panelBounds: this.sortieObjectBoundsDebug(this.cityPanelBackground),
pendingActions: Number(!cityInformationComplete) + Number(!cityDialogueComplete),
locations: this.cityLocationViews.map((view) => ({
id: view.id,
selected: this.selectedCityLocation === view.id,
bounds: this.sortieObjectBoundsDebug(view.background),
interactive: Boolean(view.background.input?.enabled)
})),
information: {
id: cityStay.information.id,
completed: cityInformationComplete,
itemReward: cityStay.information.itemReward,
briefingLines: [...cityStay.information.briefingLines],
actionBounds: this.sortieObjectBoundsDebug(this.cityInformationActionButton),
actionInteractive: Boolean(this.cityInformationActionButton?.input?.enabled)
},
dialogue: {
id: cityStay.dialogue.id,
bondId: cityStay.dialogue.bondId,
completed: cityDialogueComplete,
choiceId: this.campaign?.campDialogueChoiceIds[cityStay.dialogue.id] ?? null,
choices: cityStay.dialogue.choices.map((choice, index) => ({
id: choice.id,
label: choice.label,
rewardExp: cityStay.dialogue.rewardExp + choice.rewardExp,
bounds: this.sortieObjectBoundsDebug(this.cityDialogueChoiceButtons[index]),
interactive: Boolean(this.cityDialogueChoiceButtons[index]?.input?.enabled)
}))
},
market: {
gold: this.campaign?.gold ?? 0,
offers: cityStay.equipmentOffers.map((offer) => {
const item = getItem(offer.itemId);
const view = this.cityEquipmentOfferViews.find((candidate) => candidate.offerId === offer.id);
return {
id: offer.id,
itemId: offer.itemId,
itemName: item.name,
price: offer.price,
owned: this.campaign?.inventory[itemInventoryLabel(item.id)] ?? 0,
canBuy: (this.campaign?.gold ?? 0) >= offer.price,
buyBounds: this.sortieObjectBoundsDebug(view?.buyButton),
interactive: Boolean(view?.buyButton.input?.enabled)
};
})
}
}
: {
available: false,
id: null,
name: null,
active: false,
selectedLocation: null,
panelBounds: null,
pendingActions: 0,
locations: [],
information: null,
dialogue: null,
market: null
},
campRoster: this.campRosterLayout
? {
...this.campRosterLayout,
@@ -25115,7 +25701,9 @@ export class CampScene extends Phaser.Scene {
}, {} as UnitData['equipment'])
})),
completedCampDialogues: this.campaign.completedCampDialogues,
completedCampVisits: this.campaign.completedCampVisits
completedCampVisits: this.campaign.completedCampVisits,
campDialogueChoiceIds: this.campaign.campDialogueChoiceIds,
campVisitChoiceIds: this.campaign.campVisitChoiceIds
}
: null,
report: this.report