feat: add city stay exploration and resonance
This commit is contained in:
233
src/game/data/cityStays.ts
Normal file
233
src/game/data/cityStays.ts
Normal 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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user