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

@@ -0,0 +1,301 @@
import type { MovementTerrain } from '../audio/SoundDirector';
import type { CityStayId } from './cityStays';
import type { UnitDirection } from './unitAssets';
export type CityStayExplorationTargetKind = 'information' | 'market' | 'dialogue' | 'exit';
export type CityStayExplorationActor = {
id: string;
kind: Exclude<CityStayExplorationTargetKind, 'exit'>;
name: string;
role: string;
textureKey: string;
x: number;
y: number;
direction: UnitDirection;
};
export type CityStayExplorationBuilding = {
id: string;
label: string;
x: number;
y: number;
width: number;
height: number;
wallColor: number;
roofColor: number;
};
export type CityStayExplorationProfile = {
id: CityStayId;
heading: string;
subtitle: string;
landmarkLabel: string;
groundColor: number;
grassColor: number;
roadColor: number;
roadHighlightColor: number;
accentColor: number;
playerStart: { x: number; y: number; direction: UnitDirection };
exit: { id: 'camp-exit'; name: string; x: number; y: number };
actors: readonly [
CityStayExplorationActor,
CityStayExplorationActor,
CityStayExplorationActor
];
buildings: readonly [
CityStayExplorationBuilding,
CityStayExplorationBuilding,
CityStayExplorationBuilding
];
musicKey: string;
ambienceKey: string;
movementTerrain: MovementTerrain;
};
export const cityStayExplorationProfiles: Readonly<Record<CityStayId, CityStayExplorationProfile>> = {
xuzhou: {
id: 'xuzhou',
heading: '서주성 · 구원전 뒤',
subtitle: '성내의 장부와 시장을 살피고 미축과 다음 방위선을 준비합니다.',
landmarkLabel: '서주 중앙대로',
groundColor: 0x526044,
grassColor: 0x394b35,
roadColor: 0xb39263,
roadHighlightColor: 0xd2b77f,
accentColor: 0xd8b15f,
playerStart: { x: 760, y: 720, direction: 'north' },
exit: { id: 'camp-exit', name: '군영으로 돌아가기', x: 760, y: 895 },
actors: [
{
id: 'xuzhou-information-clerk',
kind: 'information',
name: '서주 서리',
role: '관청 장부 담당',
textureKey: 'unit-shu-strategist',
x: 365,
y: 485,
direction: 'east'
},
{
id: 'xuzhou-market-smith',
kind: 'market',
name: '서주 대장장이',
role: '일반 장비 거래',
textureKey: 'unit-shu-infantry-guard',
x: 1110,
y: 480,
direction: 'west'
},
{
id: 'xuzhou-companion-mi-zhu',
kind: 'dialogue',
name: '미축',
role: '서주의 창고와 민심',
textureKey: 'unit-shu-officer-council',
x: 1015,
y: 770,
direction: 'west'
}
],
buildings: [
{
id: 'xuzhou-office',
label: '서주 관청',
x: 105,
y: 155,
width: 410,
height: 235,
wallColor: 0xc1a06d,
roofColor: 0x6c3027
},
{
id: 'xuzhou-market',
label: '장인 거리',
x: 990,
y: 150,
width: 390,
height: 225,
wallColor: 0xbda471,
roofColor: 0x334a5d
},
{
id: 'xuzhou-guest-hall',
label: '미축 객관',
x: 1090,
y: 670,
width: 330,
height: 215,
wallColor: 0xab8b61,
roofColor: 0x4b3930
}
],
musicKey: 'camp-wayfarer',
ambienceKey: 'river-night-ambience',
movementTerrain: 'village'
},
xinye: {
id: 'xinye',
heading: '신야성 · 박망파 출진 준비',
subtitle: '객사의 숲길 정보를 모으고 제갈량의 첫 군령을 함께 정리합니다.',
landmarkLabel: '신야 느티나무 길',
groundColor: 0x435943,
grassColor: 0x2f4934,
roadColor: 0xa88a5f,
roadHighlightColor: 0xc9ad78,
accentColor: 0x9eb978,
playerStart: { x: 760, y: 720, direction: 'north' },
exit: { id: 'camp-exit', name: '군영으로 돌아가기', x: 760, y: 895 },
actors: [
{
id: 'xinye-information-guide',
kind: 'information',
name: '신야 길잡이',
role: '박망파 숲길 정찰',
textureKey: 'unit-shu-cavalry-scout',
x: 365,
y: 485,
direction: 'east'
},
{
id: 'xinye-market-smith',
kind: 'market',
name: '신야 장인',
role: '출진 장비 거래',
textureKey: 'unit-shu-infantry-guard',
x: 1110,
y: 480,
direction: 'west'
},
{
id: 'xinye-companion-zhuge',
kind: 'dialogue',
name: '제갈량',
role: '와룡의 첫 군령',
textureKey: 'unit-zhuge-liang',
x: 1015,
y: 770,
direction: 'west'
}
],
buildings: [
{
id: 'xinye-guesthouse',
label: '신야 객사',
x: 105,
y: 155,
width: 410,
height: 235,
wallColor: 0xb4a274,
roofColor: 0x3d593d
},
{
id: 'xinye-market',
label: '북문 장터',
x: 990,
y: 150,
width: 390,
height: 225,
wallColor: 0xb9a06f,
roofColor: 0x635134
},
{
id: 'xinye-command-hall',
label: '군사 관사',
x: 1090,
y: 670,
width: 330,
height: 215,
wallColor: 0xa99670,
roofColor: 0x2f4d50
}
],
musicKey: 'camp-grand-strategy',
ambienceKey: 'mountain-wind-ambience',
movementTerrain: 'forest'
},
chengdu: {
id: 'chengdu',
heading: '성도 · 항복 뒤의 첫날',
subtitle: '성도의 질서를 살피고 황권과 가맹관 북문 길을 준비합니다.',
landmarkLabel: '성도 북문대로',
groundColor: 0x5b5a43,
grassColor: 0x414a35,
roadColor: 0xbc9c68,
roadHighlightColor: 0xddc286,
accentColor: 0xe0be71,
playerStart: { x: 760, y: 720, direction: 'north' },
exit: { id: 'camp-exit', name: '군영으로 돌아가기', x: 760, y: 895 },
actors: [
{
id: 'chengdu-information-clerk',
kind: 'information',
name: '익주 서리',
role: '북문 장부 담당',
textureKey: 'unit-shu-strategist-field',
x: 365,
y: 485,
direction: 'east'
},
{
id: 'chengdu-market-smith',
kind: 'market',
name: '성도 대장장이',
role: '서량 대비 장비 거래',
textureKey: 'unit-shu-officer-vanguard',
x: 1110,
y: 480,
direction: 'west'
},
{
id: 'chengdu-companion-huang-quan',
kind: 'dialogue',
name: '황권',
role: '익주의 질서와 북문',
textureKey: 'unit-huang-quan',
x: 1015,
y: 770,
direction: 'west'
}
],
buildings: [
{
id: 'chengdu-office',
label: '성도 임시 관청',
x: 105,
y: 155,
width: 410,
height: 235,
wallColor: 0xc6ad77,
roofColor: 0x704132
},
{
id: 'chengdu-market',
label: '성도 시장',
x: 990,
y: 150,
width: 390,
height: 225,
wallColor: 0xc1a56f,
roofColor: 0x4c5365
},
{
id: 'chengdu-north-hall',
label: '황권 관사',
x: 1090,
y: 670,
width: 330,
height: 215,
wallColor: 0xb29868,
roofColor: 0x40382e
}
],
musicKey: 'camp-frontier',
ambienceKey: 'mountain-wind-ambience',
movementTerrain: 'fort'
}
};
export function getCityStayExplorationProfile(id: CityStayId) {
return cityStayExplorationProfiles[id];
}

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,

File diff suppressed because it is too large Load Diff

View File

@@ -924,6 +924,12 @@ export class TitleScene extends Phaser.Scene {
});
return;
}
if (campaign.activeCityStayId) {
await this.navigateTo('CityStayScene', {
cityStayId: campaign.activeCityStayId
});
return;
}
if (campaign.step === 'ending-complete') {
await this.navigateTo('EndingScene');
return;

View File

@@ -3,6 +3,7 @@ import Phaser from 'phaser';
export type LazySceneKey =
| 'StoryScene'
| 'PrologueVillageScene'
| 'CityStayScene'
| 'BattleScene'
| 'CampScene'
| 'EndingScene';
@@ -12,6 +13,7 @@ type LazySceneConstructor = new () => Phaser.Scene;
const lazySceneLoaders: Record<LazySceneKey, () => Promise<LazySceneConstructor>> = {
StoryScene: () => import('./StoryScene').then((module) => module.StoryScene),
PrologueVillageScene: () => import('./PrologueVillageScene').then((module) => module.PrologueVillageScene),
CityStayScene: () => import('./CityStayScene').then((module) => module.CityStayScene),
BattleScene: () => import('./BattleScene').then((module) => module.BattleScene),
CampScene: () => import('./CampScene').then((module) => module.CampScene),
EndingScene: () => import('./EndingScene').then((module) => module.EndingScene)
@@ -23,6 +25,7 @@ export function isLazySceneKey(key: string): key is LazySceneKey {
return (
key === 'StoryScene' ||
key === 'PrologueVillageScene' ||
key === 'CityStayScene' ||
key === 'BattleScene' ||
key === 'CampScene' ||
key === 'EndingScene'

View File

@@ -1,6 +1,7 @@
import { equipmentExpToNext, equipmentSlots, itemCatalog, type EquipmentSlot } from '../data/battleItems';
import { unitClasses } from '../data/battleRules';
import { battleScenarios, type BattleScenarioId } from '../data/battles';
import { findCityStayAfterBattle, type CityStayId } from '../data/cityStays';
import {
campaignRecruitUnits,
jianYongRecruitBond,
@@ -508,6 +509,7 @@ export type CampaignState = {
battleHistory: Record<string, CampaignBattleSettlement>;
latestBattleId?: string;
pendingAftermathBattleId?: BattleScenarioId;
activeCityStayId?: CityStayId;
firstBattleReport?: FirstBattleReport;
};
@@ -808,6 +810,22 @@ export function listCampaignSaveSlots(): CampaignSaveSlotSummary[] {
export function markCampaignStep(step: CampaignStep) {
const state = ensureCampaignState();
state.step = step;
if (!normalizeActiveCityStayId(state.activeCityStayId, state.latestBattleId, step)) {
delete state.activeCityStayId;
}
state.updatedAt = new Date().toISOString();
persistCampaignState(state);
return cloneCampaignState(state);
}
export function setActiveCityStayId(cityStayId?: CityStayId) {
const state = ensureCampaignState();
const normalizedCityStayId = normalizeActiveCityStayId(cityStayId, state.latestBattleId, state.step);
if (normalizedCityStayId) {
state.activeCityStayId = normalizedCityStayId;
} else {
delete state.activeCityStayId;
}
state.updatedAt = new Date().toISOString();
persistCampaignState(state);
return cloneCampaignState(state);
@@ -1026,6 +1044,7 @@ export function setFirstBattleReport(report: FirstBattleReport) {
}
state.step = retryStep;
state.latestBattleId = battleId;
delete state.activeCityStayId;
delete state.pendingAftermathBattleId;
state.updatedAt = new Date().toISOString();
persistCampaignState(state);
@@ -1075,6 +1094,7 @@ export function setFirstBattleReport(report: FirstBattleReport) {
delete state.sortieRecommendationSelection;
}
state.latestBattleId = battleId;
delete state.activeCityStayId;
state.pendingAftermathBattleId = battleId as BattleScenarioId;
state.updatedAt = new Date().toISOString();
@@ -1728,6 +1748,16 @@ function normalizeCampaignState(state: Partial<CampaignState>): CampaignState {
);
}
normalized.latestBattleId = normalizeLatestBattleId(normalized.latestBattleId, normalized.battleHistory);
const activeCityStayId = normalizeActiveCityStayId(
normalized.activeCityStayId,
normalized.latestBattleId,
normalized.step
);
if (activeCityStayId) {
normalized.activeCityStayId = activeCityStayId;
} else {
delete normalized.activeCityStayId;
}
const pendingAftermathBattleId = typeof normalized.pendingAftermathBattleId === 'string' &&
normalized.pendingAftermathBattleId in battleScenarios
? normalized.pendingAftermathBattleId as BattleScenarioId
@@ -3228,6 +3258,27 @@ function cloneCampaignState(state: CampaignState): CampaignState {
return JSON.parse(JSON.stringify(state)) as CampaignState;
}
function normalizeActiveCityStayId(
cityStayId: unknown,
latestBattleId: string | undefined,
step: CampaignStep
): CityStayId | undefined {
if (typeof cityStayId !== 'string' || !latestBattleId || !isCampCampaignStep(step)) {
return undefined;
}
if (!(latestBattleId in battleScenarios)) {
return undefined;
}
const cityStay = findCityStayAfterBattle(latestBattleId as BattleScenarioId);
const expectedCampStep = cityStay
? campaignBattleSteps[cityStay.afterBattleId]?.victory
: undefined;
return cityStay?.id === cityStayId && expectedCampStep === step
? cityStay.id
: undefined;
}
function cloneReport(report: FirstBattleReport): FirstBattleReport {
const cloned = JSON.parse(JSON.stringify(report)) as FirstBattleReport;
cloned.itemRewards = normalizeRewardStrings(cloned.itemRewards);

View File

@@ -0,0 +1,205 @@
import {
cityStayDefinitions,
type CityEquipmentOffer,
type CityInformationVisit,
type CityResonanceDialogue,
type CityResonanceDialogueChoice,
type CityStayDefinition,
type CityStayId
} from '../data/cityStays';
import {
getItem,
itemInventoryLabel,
type ItemDefinition
} from '../data/battleItems';
import {
applyCampBondExp,
applyCampVisitReward,
getCampaignState,
saveCampaignState,
type CampaignState,
type FirstBattleReport
} from './campaignState';
type CityStayActionFailure<Reason extends string> = {
ok: false;
reason: Reason;
};
export type CityInformationActionResult =
| {
ok: true;
cityStay: CityStayDefinition;
information: CityInformationVisit;
campaign: CampaignState;
}
| CityStayActionFailure<
'invalid-city' |
'invalid-action' |
'already-completed' |
'save-unavailable'
>;
export type CityResonanceActionResult =
| {
ok: true;
cityStay: CityStayDefinition;
dialogue: CityResonanceDialogue;
choice: CityResonanceDialogueChoice;
rewardExp: number;
report: FirstBattleReport;
campaign: CampaignState;
}
| CityStayActionFailure<
'invalid-city' |
'invalid-action' |
'already-completed' |
'bond-unavailable'
>;
export type CityEquipmentPurchaseResult =
| {
ok: true;
cityStay: CityStayDefinition;
offer: CityEquipmentOffer;
item: ItemDefinition;
campaign: CampaignState;
}
| CityStayActionFailure<
'invalid-city' |
'invalid-action' |
'restricted-item' |
'insufficient-gold'
>;
export function collectCityStayInformation(
cityStayId: CityStayId,
actionId: string
): CityInformationActionResult {
const cityStay = canonicalCityStay(cityStayId);
if (!cityStay) {
return { ok: false, reason: 'invalid-city' };
}
if (cityStay.information.id !== actionId) {
return { ok: false, reason: 'invalid-action' };
}
const campaign = getCampaignState();
if (!campaignSupportsCityStay(campaign, cityStay)) {
return { ok: false, reason: 'invalid-city' };
}
if (campaign.completedCampVisits.includes(cityStay.information.id)) {
return { ok: false, reason: 'already-completed' };
}
const updated = applyCampVisitReward(
cityStay.information.id,
{ itemRewards: [cityStay.information.itemReward] },
'city-information-gathered'
);
if (!updated) {
return { ok: false, reason: 'save-unavailable' };
}
return {
ok: true,
cityStay,
information: cityStay.information,
campaign: updated
};
}
export function chooseCityStayResonance(
cityStayId: CityStayId,
actionId: string
): CityResonanceActionResult {
const cityStay = canonicalCityStay(cityStayId);
if (!cityStay) {
return { ok: false, reason: 'invalid-city' };
}
const dialogue = cityStay.dialogue;
const choice = dialogue.choices.find((candidate) => candidate.id === actionId);
if (!choice) {
return { ok: false, reason: 'invalid-action' };
}
const campaign = getCampaignState();
if (!campaignSupportsCityStay(campaign, cityStay)) {
return { ok: false, reason: 'invalid-city' };
}
if (campaign.completedCampDialogues.includes(dialogue.id)) {
return { ok: false, reason: 'already-completed' };
}
const rewardExp = dialogue.rewardExp + choice.rewardExp;
const report = applyCampBondExp(dialogue.id, dialogue.bondId, rewardExp, choice.id);
if (!report) {
return { ok: false, reason: 'bond-unavailable' };
}
return {
ok: true,
cityStay,
dialogue,
choice,
rewardExp,
report,
campaign: getCampaignState()
};
}
export function purchaseCityStayEquipment(
cityStayId: CityStayId,
actionId: string
): CityEquipmentPurchaseResult {
const cityStay = canonicalCityStay(cityStayId);
if (!cityStay) {
return { ok: false, reason: 'invalid-city' };
}
const offer = cityStay.equipmentOffers.find((candidate) => candidate.id === actionId);
if (!offer) {
return { ok: false, reason: 'invalid-action' };
}
const item = getItem(offer.itemId);
if (item.id !== offer.itemId || item.slot !== offer.slot) {
return { ok: false, reason: 'invalid-action' };
}
if (item.rank !== 'common') {
return { ok: false, reason: 'restricted-item' };
}
const campaign = getCampaignState();
if (!campaignSupportsCityStay(campaign, cityStay)) {
return { ok: false, reason: 'invalid-city' };
}
if (campaign.gold < offer.price) {
return { ok: false, reason: 'insufficient-gold' };
}
campaign.gold -= offer.price;
const label = itemInventoryLabel(item.id);
campaign.inventory[label] = (campaign.inventory[label] ?? 0) + 1;
return {
ok: true,
cityStay,
offer,
item,
campaign: saveCampaignState(campaign)
};
}
function canonicalCityStay(cityStayId: CityStayId) {
return cityStayDefinitions.find((definition) => definition.id === cityStayId);
}
function campaignSupportsCityStay(
campaign: CampaignState,
cityStay: CityStayDefinition
) {
return campaign.latestBattleId === cityStay.afterBattleId &&
campaign.step.endsWith('-camp');
}

View File

@@ -1,6 +1,7 @@
import Phaser from 'phaser';
import { ambienceTracks, effectPools, effectTracks, musicTracks } from './game/audio/audioAssets';
import { soundDirector } from './game/audio/SoundDirector';
import type { CityStayId } from './game/data/cityStays';
import { BootScene } from './game/scenes/BootScene';
import { startLazySceneFromGame } from './game/scenes/lazyScenes';
import { TitleScene } from './game/scenes/TitleScene';
@@ -32,6 +33,12 @@ type DebugVillageScene = Phaser.Scene & {
debugCompleteVillage?: () => boolean;
};
type DebugCityStayScene = Phaser.Scene & {
getDebugState?: () => unknown;
debugTeleportTo?: (targetIdOrKind: string) => boolean;
debugInteractWith?: (targetIdOrKind: string) => boolean;
};
type HerosDebugApi = {
game: Phaser.Game;
activeScenes: () => string[];
@@ -39,9 +46,11 @@ type HerosDebugApi = {
battle: () => unknown;
camp: () => unknown;
village: () => unknown;
cityStay: () => unknown;
goToBattle: (battleId?: string) => Promise<void>;
goToCamp: () => Promise<void>;
goToVillage: () => Promise<void>;
goToCityStay: (cityStayId?: CityStayId) => Promise<void>;
forceBattleOutcome: (outcome: 'victory' | 'defeat') => void;
scene: (key: string) => Phaser.Scene | undefined;
toggleBattleOverlay: () => void;
@@ -93,6 +102,7 @@ function createDebugApi(game: Phaser.Game): HerosDebugApi {
const battleScene = () => scene('BattleScene') as DebugBattleScene | undefined;
const campScene = () => scene('CampScene') as DebugCampScene | undefined;
const villageScene = () => scene('PrologueVillageScene') as DebugVillageScene | undefined;
const cityStayScene = () => scene('CityStayScene') as DebugCityStayScene | undefined;
return {
game,
@@ -107,9 +117,17 @@ function createDebugApi(game: Phaser.Game): HerosDebugApi {
village: () => game.scene.isActive('PrologueVillageScene')
? villageScene()?.getDebugState?.() ?? { active: false, reason: 'PrologueVillageScene debug state is unavailable.' }
: { active: false, reason: 'PrologueVillageScene is not active yet.' },
cityStay: () => game.scene.isActive('CityStayScene')
? cityStayScene()?.getDebugState?.() ?? { active: false, reason: 'CityStayScene debug state is unavailable.' }
: { active: false, reason: 'CityStayScene is not active yet.' },
goToBattle: (battleId) => startLazySceneFromGame(game, 'BattleScene', battleId ? { battleId } : undefined),
goToCamp: () => startLazySceneFromGame(game, 'CampScene'),
goToVillage: () => startLazySceneFromGame(game, 'PrologueVillageScene'),
goToCityStay: (cityStayId) => startLazySceneFromGame(
game,
'CityStayScene',
cityStayId ? { cityStayId } : undefined
),
forceBattleOutcome: (outcome) => {
battleScene()?.debugForceBattleOutcome?.(outcome);
},