feat: add first-victory camp exploration
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 489 KiB |
@@ -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',
|
||||
|
||||
68
src/game/data/firstPursuitScoutVisit.ts
Normal file
68
src/game/data/firstPursuitScoutVisit.ts
Normal 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
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
2438
src/game/scenes/CampVisitExplorationScene.ts
Normal file
2438
src/game/scenes/CampVisitExplorationScene.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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'
|
||||
|
||||
91
src/game/state/firstPursuitScoutActions.ts
Normal file
91
src/game/state/firstPursuitScoutActions.ts
Normal 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
|
||||
};
|
||||
}
|
||||
19
src/main.ts
19
src/main.ts
@@ -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);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user