feat: add playable inter-battle city stays

This commit is contained in:
2026-07-24 02:20:10 +09:00
parent df1322b1f0
commit 412b8691b5
10 changed files with 3605 additions and 589 deletions

View File

@@ -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,