feat: add playable inter-battle city stays
This commit is contained in:
@@ -191,6 +191,7 @@ import {
|
||||
listCampaignSaveSlots,
|
||||
campaignSaveSlotCount,
|
||||
saveCampaignState,
|
||||
setActiveCityStayId,
|
||||
setCampaignSortieResonanceSelection,
|
||||
setCampaignSortieOrderSelection,
|
||||
setCampaignReserveTrainingAssignment,
|
||||
@@ -210,6 +211,11 @@ import {
|
||||
type CampaignVictoryRewardReport,
|
||||
type FirstBattleReport
|
||||
} from '../state/campaignState';
|
||||
import {
|
||||
chooseCityStayResonance,
|
||||
collectCityStayInformation,
|
||||
purchaseCityStayEquipment
|
||||
} from '../state/cityStayActions';
|
||||
import { clearCampaignBattleSavesForSlot } from '../state/battleSaveKeys';
|
||||
import { releaseTextureSource } from '../render/loaderLifecycle';
|
||||
import { palette } from '../ui/palette';
|
||||
@@ -11341,6 +11347,7 @@ export class CampScene extends Phaser.Scene {
|
||||
private activeTab: CampTab = 'status';
|
||||
private selectedCityLocation: CityStayLocationId = 'information';
|
||||
private cityPanelBackground?: Phaser.GameObjects.Rectangle;
|
||||
private cityExploreButton?: Phaser.GameObjects.Rectangle;
|
||||
private cityLocationViews: CityStayLocationView[] = [];
|
||||
private cityInformationActionButton?: Phaser.GameObjects.Rectangle;
|
||||
private cityDialogueChoiceButtons: Phaser.GameObjects.Rectangle[] = [];
|
||||
@@ -11459,6 +11466,7 @@ export class CampScene extends Phaser.Scene {
|
||||
this.campRosterPage = 0;
|
||||
this.campRosterLayout = undefined;
|
||||
this.cityPanelBackground = undefined;
|
||||
this.cityExploreButton = undefined;
|
||||
this.cityLocationViews = [];
|
||||
this.cityInformationActionButton = undefined;
|
||||
this.cityDialogueChoiceButtons = [];
|
||||
@@ -22537,7 +22545,7 @@ export class CampScene extends Phaser.Scene {
|
||||
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 }
|
||||
wordWrap: { width: 610, useAdvancedWrap: true }
|
||||
}));
|
||||
const progress = this.track(this.add.text(
|
||||
x + width - 24,
|
||||
@@ -22547,8 +22555,23 @@ export class CampScene extends Phaser.Scene {
|
||||
));
|
||||
progress.setOrigin(1, 0);
|
||||
|
||||
const locations: Array<{
|
||||
id: CityStayLocationId;
|
||||
const guide = this.track(this.add.rectangle(x + 24, y + 86, width - 48, 58, 0x17222d, 0.96));
|
||||
guide.setOrigin(0);
|
||||
guide.setStrokeStyle(1, palette.blue, 0.58);
|
||||
this.track(this.add.text(
|
||||
x + 44,
|
||||
y + 99,
|
||||
'직접 성 안을 걸으며 사람들에게 다가가 활동을 진행합니다.',
|
||||
this.textStyle(14, '#e8edf2', true)
|
||||
));
|
||||
this.track(this.add.text(
|
||||
x + 44,
|
||||
y + 122,
|
||||
'WASD·방향키·바닥 클릭으로 이동 · 가까이에서 E / Space로 대화',
|
||||
this.textStyle(11, '#9fb0bf')
|
||||
));
|
||||
|
||||
const activities: Array<{
|
||||
mark: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
@@ -22556,94 +22579,110 @@ export class CampScene extends Phaser.Scene {
|
||||
complete: boolean;
|
||||
}> = [
|
||||
{
|
||||
id: 'information',
|
||||
mark: '첩',
|
||||
title: '정보 수집',
|
||||
subtitle: cityStay.information.location,
|
||||
status: informationComplete ? '다음 전투 정보 확보' : '새 정보 확인 가능',
|
||||
status: informationComplete ? '✓ 다음 전투 정보 확보' : '관리인에게 직접 문의',
|
||||
complete: informationComplete
|
||||
},
|
||||
{
|
||||
id: 'market',
|
||||
mark: '시',
|
||||
title: '시장과 대장간',
|
||||
subtitle: `${cityStay.equipmentOffers.length}종 장비`,
|
||||
status: `군자금 ${this.campaign?.gold ?? 0}`,
|
||||
subtitle: `${cityStay.equipmentOffers.length}종 일반 장비`,
|
||||
status: `선택 활동 · 군자금 ${this.campaign?.gold ?? 0}`,
|
||||
complete: false
|
||||
},
|
||||
{
|
||||
id: 'dialogue',
|
||||
mark: '공',
|
||||
title: '동료와 대화',
|
||||
subtitle: this.cityDialogueUnitNames(cityStay),
|
||||
status: dialogueComplete ? '공명 대화 완료' : '선택에 따라 공명 상승',
|
||||
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;
|
||||
activities.forEach((activity, index) => {
|
||||
const cardX = x + 24 + index * 259;
|
||||
const cardY = y + 158;
|
||||
const card = this.track(this.add.rectangle(
|
||||
cardX,
|
||||
cardY,
|
||||
248,
|
||||
70,
|
||||
selected ? 0x283d50 : 0x151f2a,
|
||||
selected ? 0.98 : 0.9
|
||||
245,
|
||||
128,
|
||||
activity.complete ? 0x17291f : 0x151f2a,
|
||||
0.96
|
||||
));
|
||||
card.setOrigin(0);
|
||||
card.setStrokeStyle(
|
||||
selected ? 2 : 1,
|
||||
location.complete ? palette.green : selected ? palette.gold : palette.blue,
|
||||
selected ? 0.88 : 0.5
|
||||
activity.complete ? 2 : 1,
|
||||
activity.complete ? palette.green : palette.blue,
|
||||
activity.complete ? 0.86 : 0.54
|
||||
);
|
||||
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)
|
||||
const mark = this.track(this.add.circle(cardX + 31, cardY + 32, 19, 0x0d141c, 0.96));
|
||||
mark.setStrokeStyle(1, activity.complete ? palette.green : palette.gold, 0.72);
|
||||
const markText = this.track(this.add.text(
|
||||
cardX + 31,
|
||||
cardY + 32,
|
||||
activity.mark,
|
||||
this.textStyle(15, '#f2e3bf', true)
|
||||
));
|
||||
markText.setOrigin(0.5);
|
||||
this.track(this.add.text(
|
||||
cardX + 60,
|
||||
cardY + 20,
|
||||
activity.title,
|
||||
this.textStyle(15, '#f2e3bf', true)
|
||||
));
|
||||
this.track(this.add.text(cardX + 18, cardY + 58, this.compactText(activity.subtitle, 24), {
|
||||
...this.textStyle(11, '#9fb0bf'),
|
||||
wordWrap: { width: 209, useAdvancedWrap: true }
|
||||
}));
|
||||
this.track(this.add.text(
|
||||
cardX + 18,
|
||||
cardY + 101,
|
||||
activity.status,
|
||||
this.textStyle(10, activity.complete ? '#a8ffd0' : '#e8c978', 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);
|
||||
const enter = this.track(this.add.rectangle(x + width / 2, y + 335, 360, 54, 0x4a371d, 0.99));
|
||||
this.cityExploreButton = enter;
|
||||
enter.setStrokeStyle(2, palette.gold, 0.94);
|
||||
enter.setInteractive({ useHandCursor: true });
|
||||
enter.on('pointerover', () => enter.setFillStyle(0x654c25, 1));
|
||||
enter.on('pointerout', () => enter.setFillStyle(0x4a371d, 0.99));
|
||||
enter.on('pointerdown', () => this.enterCityStay(cityStay));
|
||||
const enterLabel = this.track(this.add.text(
|
||||
x + width / 2,
|
||||
y + 335,
|
||||
'성 안으로 이동',
|
||||
this.textStyle(17, '#fff2b8', true)
|
||||
));
|
||||
enterLabel.setOrigin(0.5);
|
||||
|
||||
const optionalNotice = this.track(this.add.text(
|
||||
x + width / 2,
|
||||
y + 387,
|
||||
'체류 활동은 선택 사항이며, 성 안의 남쪽 출구에서 언제든 군영으로 돌아올 수 있습니다.',
|
||||
this.textStyle(11, '#9fb0bf')
|
||||
));
|
||||
optionalNotice.setOrigin(0.5, 0);
|
||||
}
|
||||
|
||||
private enterCityStay(cityStay: CityStayDefinition) {
|
||||
const previousActiveCityStayId = getCampaignState().activeCityStayId;
|
||||
soundDirector.playSelect();
|
||||
this.startCampNavigation(
|
||||
'CityStayScene',
|
||||
{ cityStayId: cityStay.id },
|
||||
() => {
|
||||
this.campaign = setActiveCityStayId(cityStay.id);
|
||||
},
|
||||
() => {
|
||||
this.campaign = setActiveCityStayId(previousActiveCityStayId);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private renderCityInformation(
|
||||
@@ -22842,71 +22881,67 @@ export class CampScene extends Phaser.Scene {
|
||||
}
|
||||
|
||||
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) {
|
||||
const result = collectCityStayInformation(cityStay.id, cityStay.information.id);
|
||||
if (!result.ok) {
|
||||
if (result.reason === 'already-completed') {
|
||||
this.showCampNotice('이미 확보한 정보입니다.');
|
||||
return;
|
||||
}
|
||||
this.showCampNotice('정보를 장부에 기록하지 못했습니다. 전투 결과 저장 상태를 확인해 주세요.');
|
||||
return;
|
||||
}
|
||||
this.campaign = updated;
|
||||
this.report = updated.firstBattleReport ?? this.report;
|
||||
this.campaign = result.campaign;
|
||||
this.report = result.campaign.firstBattleReport ?? this.report;
|
||||
soundDirector.playEffect('exp-gain', { volume: 0.24, stopAfterMs: 700 });
|
||||
this.showCampNotice(
|
||||
`정보 확보 · ${cityStay.information.title}\n다음 전투 브리핑에 ${cityStay.city.name} 현지 정보가 추가됩니다.`
|
||||
`정보 확보 · ${result.information.title}\n다음 전투 브리핑에 ${result.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) {
|
||||
const result = chooseCityStayResonance(cityStay.id, choice.id);
|
||||
if (!result.ok) {
|
||||
if (result.reason === 'already-completed') {
|
||||
this.showCampNotice('이미 완료된 공명 대화입니다.');
|
||||
return;
|
||||
}
|
||||
this.showCampNotice('두 동료의 공명 관계를 찾지 못했습니다. 합류 상태를 확인해 주세요.');
|
||||
return;
|
||||
}
|
||||
this.report = updated;
|
||||
this.campaign = getCampaignState();
|
||||
this.report = result.report;
|
||||
this.campaign = result.campaign;
|
||||
soundDirector.playEffect('exp-gain', { volume: 0.28, stopAfterMs: 700 });
|
||||
this.showCampNotice(`공명 +${rewardExp} · ${choice.label}\n${choice.response}`);
|
||||
this.showCampNotice(`공명 +${result.rewardExp} · ${result.choice.label}\n${result.choice.response}`);
|
||||
this.render();
|
||||
}
|
||||
|
||||
private buyCityEquipment(offer: CityEquipmentOffer) {
|
||||
const cityStay = this.currentCityStay();
|
||||
if (!cityStay || !cityStay.equipmentOffers.some((candidate) => candidate.id === offer.id)) {
|
||||
if (!cityStay) {
|
||||
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('군자금이 부족합니다.');
|
||||
const result = purchaseCityStayEquipment(cityStay.id, offer.id);
|
||||
if (!result.ok) {
|
||||
if (result.reason === 'restricted-item') {
|
||||
this.showCampNotice('보물 장비는 일반 시장에서 거래할 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
if (result.reason === 'insufficient-gold') {
|
||||
this.showCampNotice('군자금이 부족합니다.');
|
||||
return;
|
||||
}
|
||||
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.campaign = result.campaign;
|
||||
this.report = this.campaign.firstBattleReport ?? this.report;
|
||||
soundDirector.playSelect();
|
||||
this.showCampNotice(`${cityStay.city.name} 시장 · ${item.name} 구입 · 군자금 -${offer.price}`);
|
||||
this.showCampNotice(
|
||||
`${result.cityStay.city.name} 시장 · ${result.item.name} 구입 · 군자금 -${result.offer.price}`
|
||||
);
|
||||
this.render();
|
||||
}
|
||||
|
||||
@@ -24530,6 +24565,7 @@ export class CampScene extends Phaser.Scene {
|
||||
this.contentObjects = [];
|
||||
this.campRosterLayout = undefined;
|
||||
this.cityPanelBackground = undefined;
|
||||
this.cityExploreButton = undefined;
|
||||
this.cityLocationViews = [];
|
||||
this.cityInformationActionButton = undefined;
|
||||
this.cityDialogueChoiceButtons = [];
|
||||
@@ -24745,8 +24781,11 @@ export class CampScene extends Phaser.Scene {
|
||||
afterBattleId: cityStay.afterBattleId,
|
||||
nextBattleId: cityStay.nextBattleId,
|
||||
active: this.activeTab === 'city',
|
||||
mode: 'exploration-entry',
|
||||
selectedLocation: this.selectedCityLocation,
|
||||
panelBounds: this.sortieObjectBoundsDebug(this.cityPanelBackground),
|
||||
explorationButtonBounds: this.sortieObjectBoundsDebug(this.cityExploreButton),
|
||||
explorationInteractive: Boolean(this.cityExploreButton?.input?.enabled),
|
||||
pendingActions: Number(!cityInformationComplete) + Number(!cityDialogueComplete),
|
||||
locations: this.cityLocationViews.map((view) => ({
|
||||
id: view.id,
|
||||
@@ -24798,8 +24837,11 @@ export class CampScene extends Phaser.Scene {
|
||||
id: null,
|
||||
name: null,
|
||||
active: false,
|
||||
mode: null,
|
||||
selectedLocation: null,
|
||||
panelBounds: null,
|
||||
explorationButtonBounds: null,
|
||||
explorationInteractive: false,
|
||||
pendingActions: 0,
|
||||
locations: [],
|
||||
information: null,
|
||||
|
||||
Reference in New Issue
Block a user