feat: add first-victory camp exploration

This commit is contained in:
2026-07-27 09:17:09 +09:00
parent 89c755ca42
commit e9fcd27611
17 changed files with 4557 additions and 113 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 489 KiB

View File

@@ -19,6 +19,7 @@ export const explorationCharacterTextureKeyByUnitTextureKey = {
} as const;
export const explorationCharacterNamedTextureKeyFallbacks = {
'exploration-jian-yong': 'unit-shu-officer',
'exploration-zhuo-recruiting-clerk': 'unit-shu-officer',
'exploration-zhuo-villager': 'unit-shu-infantry',
'exploration-zhuo-quartermaster': 'unit-shu-officer',

View File

@@ -0,0 +1,68 @@
import {
firstPursuitScoutChoiceIds,
firstPursuitScoutSourceBattleId,
firstPursuitScoutVisitId,
type FirstPursuitScoutChoiceId
} from './firstPursuitScoutMemory';
export type FirstPursuitScoutVisitChoice = {
readonly id: FirstPursuitScoutChoiceId;
readonly label: string;
readonly response: string;
readonly bondExp: number;
readonly itemRewards: readonly string[];
};
type FirstPursuitScoutVisitDefinition = {
readonly id: typeof firstPursuitScoutVisitId;
readonly title: string;
readonly location: string;
readonly availableAfterBattleIds: readonly [
typeof firstPursuitScoutSourceBattleId
];
readonly bondId: string;
readonly description: string;
readonly lines: readonly string[];
readonly choices: readonly FirstPursuitScoutVisitChoice[];
};
export const firstPursuitScoutVisitDefinition = {
id: firstPursuitScoutVisitId,
title: '북문 추격로 정찰',
location: '탁현 북문 정찰막',
availableAfterBattleIds: [firstPursuitScoutSourceBattleId],
bondId: 'liu-bei__jian-yong',
description:
'간옹과 황건 잔당의 두 갈래 길을 살피고 정찰 기록을 출진 준비에 올립니다.',
lines: [
'간옹: 발자국이 강가와 마을길로 갈렸습니다.',
'유비: 매복도 찾고 백성의 길도 지켜야 하오.',
'정찰 지도에 두 길을 나란히 표시했습니다.'
],
choices: [
{
id: firstPursuitScoutChoiceIds[0],
label: '강가 매복 흔적을 쫓는다',
response:
'간옹: 강변의 꺾인 갈대와 버려진 횃불을 표시했습니다. 상처약도 챙겨 이 기록을 출진 준비에 올리겠습니다.',
bondExp: 10,
itemRewards: ['상처약 1']
},
{
id: firstPursuitScoutChoiceIds[1],
label: '마을 구호 길을 표시한다',
response:
'간옹: 피난민이 돌아올 샛길과 군량을 나눌 곳을 적었습니다. 콩 한 자루와 이 기록을 다음 전투 준비에 보태겠습니다.',
bondExp: 10,
itemRewards: ['콩 1']
}
]
} as const satisfies FirstPursuitScoutVisitDefinition;
export function getFirstPursuitScoutVisitChoice(
choiceId: string
): FirstPursuitScoutVisitChoice | undefined {
return firstPursuitScoutVisitDefinition.choices.find(
(choice) => choice.id === choiceId
);
}

View File

@@ -38,6 +38,7 @@ import {
firstPursuitScoutVisitId,
resolveFirstPursuitScoutMemory
} from '../data/firstPursuitScoutMemory';
import { firstPursuitScoutVisitDefinition } from '../data/firstPursuitScoutVisit';
import {
findCityStayAfterBattle,
type CityEquipmentOffer,
@@ -8401,33 +8402,13 @@ const campDialogues: CampDialogue[] = [
const campVisits: CampVisitDefinition[] = [
{
id: 'first-pursuit-scout-tent',
title: '북문 추격로 정찰',
location: '탁현 북문 정찰막',
availableAfterBattleIds: [campBattleIds.first],
bondId: 'liu-bei__jian-yong',
description: '간옹과 황건 잔당의 두 갈래 길을 살피고 정찰 기록을 출진 준비에 올립니다.',
lines: [
'간옹: 발자국이 강가와 마을길로 갈렸습니다.',
'유비: 매복도 찾고 백성의 길도 지켜야 하오.',
'정찰 지도에 두 길을 나란히 표시했습니다.'
],
choices: [
{
id: 'trace-river-ambush',
label: '강가 매복 흔적을 쫓는다',
response: '간옹: 강변의 꺾인 갈대와 버려진 횃불을 표시했습니다. 상처약도 챙겨 이 기록을 출진 준비에 올리겠습니다.',
bondExp: 10,
itemRewards: ['상처약 1']
},
{
id: 'mark-village-relief',
label: '마을 구호 길을 표시한다',
response: '간옹: 피난민이 돌아올 샛길과 군량을 나눌 곳을 적었습니다. 콩 한 자루와 이 기록을 다음 전투 준비에 보태겠습니다.',
bondExp: 10,
itemRewards: ['콩 1']
}
]
...firstPursuitScoutVisitDefinition,
availableAfterBattleIds: [...firstPursuitScoutVisitDefinition.availableAfterBattleIds],
lines: [...firstPursuitScoutVisitDefinition.lines],
choices: firstPursuitScoutVisitDefinition.choices.map((choice) => ({
...choice,
itemRewards: [...choice.itemRewards]
}))
},
{
id: 'jingzhou-scholar-rumors',
@@ -11407,6 +11388,7 @@ export class CampScene extends Phaser.Scene {
private campDialogueChoiceButtons: Record<string, Phaser.GameObjects.Rectangle> = {};
private campDialogueBodyText?: Phaser.GameObjects.Text;
private selectedVisitId = campVisits[0].id;
private firstPursuitExplorationButton?: Phaser.GameObjects.Rectangle;
private equipmentInventoryPage = 0;
private equipmentPanelBackground?: Phaser.GameObjects.Rectangle;
private equipmentInventoryPreviousButton?: Phaser.GameObjects.Rectangle;
@@ -11531,6 +11513,7 @@ export class CampScene extends Phaser.Scene {
this.campDialogueRowButtons = {};
this.campDialogueChoiceButtons = {};
this.campDialogueBodyText = undefined;
this.firstPursuitExplorationButton = undefined;
this.dialogueObjects = [];
this.sortieObjects = [];
this.sortieComparisonPanelView = undefined;
@@ -11642,6 +11625,14 @@ export class CampScene extends Phaser.Scene {
} else if (this.activeTab === 'city') {
this.activeTab = 'status';
}
const pendingFirstPursuitScoutVisit = this.availableCampVisits().some(
(visit) =>
visit.id === firstPursuitScoutVisitId &&
!this.completedCampVisits().includes(visit.id)
);
if (!cityStay && pendingFirstPursuitScoutVisit && this.activeTab === 'status') {
this.activeTab = 'visit';
}
soundDirector.playSoundscape({
musicKey: this.campSoundscape.music.trackKey,
ambienceKey: this.campSoundscape.ambience.trackKey,
@@ -13883,7 +13874,7 @@ export class CampScene extends Phaser.Scene {
: width < 70
? 'N'
: 'NEW';
const newBadge = tab === 'city' || tab === 'dialogue' || tab === 'supplies' || tab === 'equipment'
const newBadge = tab === 'city' || tab === 'dialogue' || tab === 'visit' || tab === 'supplies' || tab === 'equipment'
? this.scaleLegacyCampUi(this.add.text(x + width / 2 - 10, y - 17, badgeLabel, {
...this.textStyle(9, '#fff2b8', true),
backgroundColor: '#8a3f25',
@@ -13911,6 +13902,9 @@ export class CampScene extends Phaser.Scene {
const active = this.activeTab === tab;
const pendingFirstBattleFollowup = tab === 'dialogue' && this.hasPendingFirstBattleCampFollowup();
const pendingCamaraderieMemory = tab === 'dialogue' && this.hasPendingFirstBattleCamaraderieMemory();
const pendingCampVisit = tab === 'visit' && this.availableCampVisits().some(
(visit) => !this.completedCampVisits().includes(visit.id)
);
bg.setFillStyle(active ? 0x2b4052 : hovered ? 0x243747 : 0x182431, active || hovered ? 0.98 : 0.94);
bg.setStrokeStyle(active || hovered ? 2 : 1, active || hovered ? accentColor : palette.blue, active ? 0.96 : hovered ? 0.82 : 0.62);
text.setColor(active || hovered ? '#fff2b8' : '#f2e3bf');
@@ -13923,6 +13917,7 @@ export class CampScene extends Phaser.Scene {
newBadge?.setVisible(Boolean(
(tab === 'city' && this.hasPendingCityStayActions()) ||
(tab === 'dialogue' && (pendingFirstBattleFollowup || pendingCamaraderieMemory)) ||
pendingCampVisit ||
(tab === 'supplies' && pendingCategories.has('supplies')) ||
(tab === 'equipment' && pendingCategories.has('equipment'))
));
@@ -23366,6 +23361,48 @@ export class CampScene extends Phaser.Scene {
return;
}
if (visit.id === firstPursuitScoutVisitId) {
const guide = this.track(this.add.rectangle(x + 18, y + height - 102, width - 36, 84, 0x151f2a, 0.96));
guide.setOrigin(0);
guide.setStrokeStyle(1, palette.blue, 0.62);
this.track(this.add.text(
x + 32,
y + height - 90,
'유비를 움직여 간옹에게 직접 말을 거세요.',
this.textStyle(13, '#d4dce6', true)
));
this.track(this.add.text(
x + 32,
y + height - 68,
'WASD·방향키 이동 · E/Space 대화',
this.textStyle(11, '#9fb0bf')
));
const buttonY = y + height - 36;
const button = this.track(this.add.rectangle(x + width / 2, buttonY, width - 64, 34, 0x4a371d, 0.98));
button.setStrokeStyle(2, palette.gold, 0.94);
button.setInteractive({ useHandCursor: true });
const openExploration = () => {
soundDirector.playSelect();
this.startCampNavigation('CampVisitExplorationScene', { visitId: visit.id });
};
button.on('pointerover', () => button.setFillStyle(0x654c25, 1));
button.on('pointerout', () => button.setFillStyle(0x4a371d, 0.98));
button.on('pointerdown', openExploration);
this.firstPursuitExplorationButton = button;
const label = this.track(this.add.text(
x + width / 2,
buttonY,
'정찰막으로 이동',
this.textStyle(14, '#fff2b8', true)
));
label.setOrigin(0.5);
label.setInteractive({ useHandCursor: true });
label.on('pointerdown', openExploration);
return;
}
visit.choices.forEach((choice, index) => {
const choiceY = y + height - 80 + index * 42;
const button = this.track(this.add.rectangle(x + width / 2, choiceY, width - 36, 34, 0x1a2630, 0.96));
@@ -24904,6 +24941,7 @@ export class CampScene extends Phaser.Scene {
this.campDialogueRowButtons = {};
this.campDialogueChoiceButtons = {};
this.campDialogueBodyText = undefined;
this.firstPursuitExplorationButton = undefined;
this.campRosterLayout = undefined;
this.cityPanelBackground = undefined;
this.cityExploreButton = undefined;
@@ -25227,6 +25265,15 @@ export class CampScene extends Phaser.Scene {
visitId: firstPursuitScoutVisitId,
available: Boolean(firstPursuitScoutVisit),
completed: this.completedCampVisits().includes(firstPursuitScoutVisitId),
mode: this.completedCampVisits().includes(firstPursuitScoutVisitId)
? 'completed'
: firstPursuitScoutVisit
? 'walk-to-npc'
: 'unavailable',
explorationButtonBounds: this.sortieInteractiveObjectBoundsDebug(
this.firstPursuitExplorationButton
),
explorationInteractive: Boolean(this.firstPursuitExplorationButton?.input?.enabled),
choiceId: this.campaign?.campVisitChoiceIds[firstPursuitScoutVisitId] ?? null,
choices: firstPursuitScoutVisit?.choices.map((choice) => ({
id: choice.id,

File diff suppressed because it is too large Load Diff

View File

@@ -5,6 +5,7 @@ export type LazySceneKey =
| 'PrologueVillageScene'
| 'PrologueMilitiaCampScene'
| 'CityStayScene'
| 'CampVisitExplorationScene'
| 'BattleScene'
| 'CampScene'
| 'EndingScene';
@@ -16,6 +17,8 @@ const lazySceneLoaders: Record<LazySceneKey, () => Promise<LazySceneConstructor>
PrologueVillageScene: () => import('./PrologueVillageScene').then((module) => module.PrologueVillageScene),
PrologueMilitiaCampScene: () => import('./PrologueMilitiaCampScene').then((module) => module.PrologueMilitiaCampScene),
CityStayScene: () => import('./CityStayScene').then((module) => module.CityStayScene),
CampVisitExplorationScene: () =>
import('./CampVisitExplorationScene').then((module) => module.CampVisitExplorationScene),
BattleScene: () => import('./BattleScene').then((module) => module.BattleScene),
CampScene: () => import('./CampScene').then((module) => module.CampScene),
EndingScene: () => import('./EndingScene').then((module) => module.EndingScene)
@@ -29,6 +32,7 @@ export function isLazySceneKey(key: string): key is LazySceneKey {
key === 'PrologueVillageScene' ||
key === 'PrologueMilitiaCampScene' ||
key === 'CityStayScene' ||
key === 'CampVisitExplorationScene' ||
key === 'BattleScene' ||
key === 'CampScene' ||
key === 'EndingScene'

View File

@@ -0,0 +1,91 @@
import {
firstPursuitScoutSourceBattleId,
firstPursuitScoutVisitId
} from '../data/firstPursuitScoutMemory';
import {
firstPursuitScoutVisitDefinition,
getFirstPursuitScoutVisitChoice,
type FirstPursuitScoutVisitChoice
} from '../data/firstPursuitScoutVisit';
import {
applyCampVisitReward,
getCampaignState,
setCampaignState,
type CampaignState
} from './campaignState';
type FirstPursuitScoutActionFailure =
| 'invalid-campaign'
| 'invalid-choice'
| 'already-completed'
| 'save-unavailable';
export type FirstPursuitScoutActionResult =
| {
ok: true;
choice: FirstPursuitScoutVisitChoice;
campaign: CampaignState;
}
| {
ok: false;
reason: FirstPursuitScoutActionFailure;
};
export function completeFirstPursuitScoutVisit(
choiceId: string
): FirstPursuitScoutActionResult {
let campaign: CampaignState;
try {
campaign = getCampaignState();
} catch {
return { ok: false, reason: 'save-unavailable' };
}
const settlement = campaign.battleHistory[firstPursuitScoutSourceBattleId];
const report = campaign.firstBattleReport;
if (
campaign.step !== 'first-camp' ||
campaign.latestBattleId !== firstPursuitScoutSourceBattleId ||
settlement?.outcome !== 'victory' ||
report?.battleId !== firstPursuitScoutSourceBattleId ||
report.outcome !== 'victory'
) {
return { ok: false, reason: 'invalid-campaign' };
}
const choice = getFirstPursuitScoutVisitChoice(choiceId);
if (!choice) {
return { ok: false, reason: 'invalid-choice' };
}
if (campaign.completedCampVisits.includes(firstPursuitScoutVisitId)) {
return { ok: false, reason: 'already-completed' };
}
let updated: CampaignState | undefined;
try {
updated = applyCampVisitReward(
firstPursuitScoutVisitId,
{
bondId: firstPursuitScoutVisitDefinition.bondId,
bondExp: choice.bondExp,
itemRewards: [...choice.itemRewards]
},
choice.id
);
} catch {
try {
setCampaignState(campaign);
} catch {
// setCampaignState restores the in-memory snapshot before persistence.
}
return { ok: false, reason: 'save-unavailable' };
}
if (!updated) {
return { ok: false, reason: 'save-unavailable' };
}
return {
ok: true,
choice,
campaign: updated
};
}

View File

@@ -47,6 +47,10 @@ type DebugCityStayScene = Phaser.Scene & {
debugInteractWith?: (targetIdOrKind: string) => boolean;
};
type DebugCampVisitExplorationScene = Phaser.Scene & {
getDebugState?: () => unknown;
};
type HerosDebugApi = {
game: Phaser.Game;
activeScenes: () => string[];
@@ -56,11 +60,13 @@ type HerosDebugApi = {
village: () => unknown;
militiaCamp: () => unknown;
cityStay: () => unknown;
campVisitExploration: () => unknown;
goToBattle: (battleId?: string) => Promise<void>;
goToCamp: () => Promise<void>;
goToVillage: () => Promise<void>;
goToMilitiaCamp: () => Promise<void>;
goToCityStay: (cityStayId?: CityStayId) => Promise<void>;
goToCampVisitExploration: (visitId?: string) => Promise<void>;
forceBattleOutcome: (outcome: 'victory' | 'defeat') => void;
scene: (key: string) => Phaser.Scene | undefined;
toggleBattleOverlay: () => void;
@@ -114,6 +120,8 @@ function createDebugApi(game: Phaser.Game): HerosDebugApi {
const villageScene = () => scene('PrologueVillageScene') as DebugVillageScene | undefined;
const militiaCampScene = () => scene('PrologueMilitiaCampScene') as DebugMilitiaCampScene | undefined;
const cityStayScene = () => scene('CityStayScene') as DebugCityStayScene | undefined;
const campVisitExplorationScene = () =>
scene('CampVisitExplorationScene') as DebugCampVisitExplorationScene | undefined;
return {
game,
@@ -137,6 +145,12 @@ function createDebugApi(game: Phaser.Game): HerosDebugApi {
cityStay: () => game.scene.isActive('CityStayScene')
? cityStayScene()?.getDebugState?.() ?? { active: false, reason: 'CityStayScene debug state is unavailable.' }
: { active: false, reason: 'CityStayScene is not active yet.' },
campVisitExploration: () => game.scene.isActive('CampVisitExplorationScene')
? campVisitExplorationScene()?.getDebugState?.() ?? {
active: false,
reason: 'CampVisitExplorationScene debug state is unavailable.'
}
: { active: false, reason: 'CampVisitExplorationScene is not active yet.' },
goToBattle: (battleId) => startLazySceneFromGame(game, 'BattleScene', battleId ? { battleId } : undefined),
goToCamp: () => startLazySceneFromGame(game, 'CampScene'),
goToVillage: () => startLazySceneFromGame(game, 'PrologueVillageScene'),
@@ -146,6 +160,11 @@ function createDebugApi(game: Phaser.Game): HerosDebugApi {
'CityStayScene',
cityStayId ? { cityStayId } : undefined
),
goToCampVisitExploration: (visitId = 'first-pursuit-scout-tent') => startLazySceneFromGame(
game,
'CampVisitExplorationScene',
{ visitId }
),
forceBattleOutcome: (outcome) => {
battleScene()?.debugForceBattleOutcome?.(outcome);
},