feat: add playable Zhuo prologue village
This commit is contained in:
204
src/game/data/prologueVillage.ts
Normal file
204
src/game/data/prologueVillage.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
import { prologuePages, type StoryPage } from './scenario';
|
||||
|
||||
export const prologueVillageId = 'zhuo-prologue';
|
||||
|
||||
export const prologueVillageRequiredObjectiveIds = [
|
||||
'meet-zhang-fei',
|
||||
'meet-guan-yu',
|
||||
'check-supplies'
|
||||
] as const;
|
||||
|
||||
export type PrologueVillageRequiredObjectiveId =
|
||||
(typeof prologueVillageRequiredObjectiveIds)[number];
|
||||
|
||||
export const prologueVillageObjectiveDefinitions: ReadonlyArray<{
|
||||
id: PrologueVillageRequiredObjectiveId | 'make-oath';
|
||||
label: string;
|
||||
shortLabel: string;
|
||||
location: string;
|
||||
}> = [
|
||||
{
|
||||
id: 'meet-zhang-fei',
|
||||
label: '장터 주점에서 장비와 이야기하기',
|
||||
shortLabel: '장비와 대화',
|
||||
location: '서쪽 주점'
|
||||
},
|
||||
{
|
||||
id: 'meet-guan-yu',
|
||||
label: '동쪽 장터에서 관우의 정찰 듣기',
|
||||
shortLabel: '관우와 대화',
|
||||
location: '동쪽 장터'
|
||||
},
|
||||
{
|
||||
id: 'check-supplies',
|
||||
label: '의병소에서 무기와 보급 확인하기',
|
||||
shortLabel: '출전 준비',
|
||||
location: '남동쪽 의병소'
|
||||
},
|
||||
{
|
||||
id: 'make-oath',
|
||||
label: '복숭아 동산에서 두 사람과 결의하기',
|
||||
shortLabel: '도원결의',
|
||||
location: '북쪽 복숭아 동산'
|
||||
}
|
||||
];
|
||||
|
||||
export type PrologueVillageDialogueLine = {
|
||||
speaker: string;
|
||||
text: string;
|
||||
textureKey?: string;
|
||||
};
|
||||
|
||||
export type PrologueVillageNpcDefinition = {
|
||||
id: string;
|
||||
name: string;
|
||||
role: string;
|
||||
textureKey: string;
|
||||
x: number;
|
||||
y: number;
|
||||
direction: 'south' | 'east' | 'north' | 'west';
|
||||
objectiveId?: PrologueVillageRequiredObjectiveId;
|
||||
dialogue: PrologueVillageDialogueLine[];
|
||||
repeatDialogue?: PrologueVillageDialogueLine[];
|
||||
};
|
||||
|
||||
export const prologueVillageNpcDefinitions: readonly PrologueVillageNpcDefinition[] = [
|
||||
{
|
||||
id: 'zhang-fei',
|
||||
name: '장비',
|
||||
role: '뜻을 함께할 호걸',
|
||||
textureKey: 'unit-zhang-fei',
|
||||
x: 360,
|
||||
y: 495,
|
||||
direction: 'east',
|
||||
objectiveId: 'meet-zhang-fei',
|
||||
dialogue: [
|
||||
{
|
||||
speaker: '장비',
|
||||
textureKey: 'unit-zhang-fei',
|
||||
text: '나라가 어지러운데 뜻 있는 사내가 어찌 팔짱만 끼고 있겠소! 내 재산을 내어 의병을 모으겠소.'
|
||||
},
|
||||
{
|
||||
speaker: '유비',
|
||||
textureKey: 'unit-liu-bei',
|
||||
text: '백성을 지키려는 마음이 같다면 힘을 합칩시다. 먼저 싸울 사람과 물자를 갖추어야 하오.'
|
||||
},
|
||||
{
|
||||
speaker: '장비',
|
||||
textureKey: 'unit-zhang-fei',
|
||||
text: '좋소! 내가 장터 사람들을 모아 두겠소. 의병소의 준비를 확인한 뒤 복숭아 동산에서 다시 만납시다.'
|
||||
}
|
||||
],
|
||||
repeatDialogue: [
|
||||
{
|
||||
speaker: '장비',
|
||||
textureKey: 'unit-zhang-fei',
|
||||
text: '사람들에게 뜻을 전하고 있소. 관우와 의병소도 둘러본 뒤 동산으로 오시오!'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'guan-yu',
|
||||
name: '관우',
|
||||
role: '길목을 살피는 무인',
|
||||
textureKey: 'unit-guan-yu',
|
||||
x: 1110,
|
||||
y: 460,
|
||||
direction: 'west',
|
||||
objectiveId: 'meet-guan-yu',
|
||||
dialogue: [
|
||||
{
|
||||
speaker: '관우',
|
||||
textureKey: 'unit-guan-yu',
|
||||
text: '북쪽 길을 살펴보니 황건 잔당이 마을 어귀로 내려오고 있습니다. 더 늦으면 백성이 위험합니다.'
|
||||
},
|
||||
{
|
||||
speaker: '유비',
|
||||
textureKey: 'unit-liu-bei',
|
||||
text: '우리가 먼저 길목을 막아야겠군요. 함께 백성을 구하는 길을 열어 주시겠소?'
|
||||
},
|
||||
{
|
||||
speaker: '관우',
|
||||
textureKey: 'unit-guan-yu',
|
||||
text: '뜻이 올바르다면 천 리라도 함께하겠습니다. 준비를 마치고 복숭아 동산에서 맹세하지요.'
|
||||
}
|
||||
],
|
||||
repeatDialogue: [
|
||||
{
|
||||
speaker: '관우',
|
||||
textureKey: 'unit-guan-yu',
|
||||
text: '적은 보병을 앞세우고 뒤에서 활을 쏠 것입니다. 준비가 끝나는 대로 북문으로 나가야 합니다.'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'quartermaster',
|
||||
name: '의병소 관리인',
|
||||
role: '무기와 보급 담당',
|
||||
textureKey: 'unit-shu-officer',
|
||||
x: 1045,
|
||||
y: 760,
|
||||
direction: 'east',
|
||||
objectiveId: 'check-supplies',
|
||||
dialogue: [
|
||||
{
|
||||
speaker: '의병소 관리인',
|
||||
textureKey: 'unit-shu-officer',
|
||||
text: '장비 공이 맡긴 돈으로 창과 연습검을 손질했습니다. 콩과 상처약도 각 조에 나누어 두었습니다.'
|
||||
},
|
||||
{
|
||||
speaker: '유비',
|
||||
textureKey: 'unit-liu-bei',
|
||||
text: '좋습니다. 무기보다 먼저 사람을 살피고, 다친 이는 곧바로 뒤로 물리도록 전해 주시오.'
|
||||
},
|
||||
{
|
||||
speaker: '의병소 관리인',
|
||||
textureKey: 'unit-shu-officer',
|
||||
text: '출전 준비를 마쳤습니다. 이제 동료들과 결의하면 북문을 열겠습니다.'
|
||||
}
|
||||
],
|
||||
repeatDialogue: [
|
||||
{
|
||||
speaker: '의병소 관리인',
|
||||
textureKey: 'unit-shu-officer',
|
||||
text: '무기와 보급은 모두 나누었습니다. 북문을 나서기 전에 동산에서 동료들과 합류하십시오.'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'market-villager',
|
||||
name: '탁현 주민',
|
||||
role: '장터 소식',
|
||||
textureKey: 'unit-shu-infantry',
|
||||
x: 745,
|
||||
y: 620,
|
||||
direction: 'south',
|
||||
dialogue: [
|
||||
{
|
||||
speaker: '탁현 주민',
|
||||
textureKey: 'unit-shu-infantry',
|
||||
text: '서쪽 주점의 장비 공과 동쪽 장터의 긴 수염 무인이 의병 이야기를 나누고 있었습니다.'
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const openingPageIds = new Set(['yellow-turban-chaos', 'liu-bei-resolve']);
|
||||
const departurePageIds = new Set([
|
||||
'first-sortie',
|
||||
'yellow-turban-nearby',
|
||||
'first-battle-plan-talk',
|
||||
'battle-briefing'
|
||||
]);
|
||||
|
||||
export function prologueOpeningPages(): StoryPage[] {
|
||||
return selectProloguePages(openingPageIds);
|
||||
}
|
||||
|
||||
export function prologueDeparturePages(): StoryPage[] {
|
||||
return selectProloguePages(departurePageIds);
|
||||
}
|
||||
|
||||
function selectProloguePages(pageIds: ReadonlySet<string>) {
|
||||
return prologuePages.filter((page) => pageIds.has(page.id));
|
||||
}
|
||||
@@ -4025,7 +4025,12 @@ export class BattleScene extends Phaser.Scene {
|
||||
this.resetSortieOrderHudState();
|
||||
const campaign = getCampaignState();
|
||||
this.resetBattleData(campaign);
|
||||
if (campaign.step === 'new' || campaign.step === 'prologue' || campaign.step === 'first-battle') {
|
||||
if (
|
||||
campaign.step === 'new' ||
|
||||
campaign.step === 'prologue' ||
|
||||
campaign.step === 'prologue-town' ||
|
||||
campaign.step === 'first-battle'
|
||||
) {
|
||||
markCampaignStep('first-battle');
|
||||
}
|
||||
const { width, height } = this.scale;
|
||||
|
||||
1432
src/game/scenes/PrologueVillageScene.ts
Normal file
1432
src/game/scenes/PrologueVillageScene.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -46,7 +46,8 @@ import {
|
||||
type StoryCutsceneMarker,
|
||||
type StoryCutsceneReward
|
||||
} from '../data/storyCutscenes';
|
||||
import { prologuePages, type PortraitKey, type StoryPage } from '../data/scenario';
|
||||
import { prologueOpeningPages } from '../data/prologueVillage';
|
||||
import { type PortraitKey, type StoryPage } from '../data/scenario';
|
||||
import { campaignVictoryRewardPresentation, getFirstBattleReport } from '../state/campaignState';
|
||||
import { releaseTextureSource } from '../render/loaderLifecycle';
|
||||
import { isVisualMotionReduced } from '../settings/visualMotion';
|
||||
@@ -187,9 +188,9 @@ const firstBattleWarmupUnitTextureKeys = [
|
||||
|
||||
export class StoryScene extends Phaser.Scene {
|
||||
private pageIndex = 0;
|
||||
private pages: StoryPage[] = prologuePages;
|
||||
private selectedStoryBackgroundKeys = storyBackgroundKeysForPages(prologuePages);
|
||||
private nextScene = 'BattleScene';
|
||||
private pages: StoryPage[] = prologueOpeningPages();
|
||||
private selectedStoryBackgroundKeys = storyBackgroundKeysForPages(prologueOpeningPages());
|
||||
private nextScene = 'PrologueVillageScene';
|
||||
private nextSceneData?: Record<string, unknown>;
|
||||
private presentationBattleId?: string;
|
||||
private presentationStage: Extract<CampaignPresentationStage, 'story' | 'aftermath'> = 'story';
|
||||
@@ -230,9 +231,9 @@ export class StoryScene extends Phaser.Scene {
|
||||
}
|
||||
|
||||
init(data?: StorySceneData) {
|
||||
this.pages = data?.pages?.length ? data.pages : prologuePages;
|
||||
this.pages = data?.pages?.length ? data.pages : prologueOpeningPages();
|
||||
this.selectedStoryBackgroundKeys = storyBackgroundKeysForPages(this.pages);
|
||||
this.nextScene = data?.nextScene ?? 'BattleScene';
|
||||
this.nextScene = data?.nextScene ?? 'PrologueVillageScene';
|
||||
this.nextSceneData = data?.nextSceneData;
|
||||
this.presentationBattleId = data?.presentationBattleId;
|
||||
this.presentationStage = data?.presentationStage ?? 'story';
|
||||
@@ -301,6 +302,8 @@ export class StoryScene extends Phaser.Scene {
|
||||
advanceHint:
|
||||
isLastPage && this.nextScene === 'BattleScene'
|
||||
? 'battle-deployment'
|
||||
: isLastPage && this.nextScene === 'PrologueVillageScene'
|
||||
? 'village-exploration'
|
||||
: isLastPage && this.nextScene === 'CampScene'
|
||||
? 'camp-return'
|
||||
: isLastPage
|
||||
@@ -576,6 +579,11 @@ export class StoryScene extends Phaser.Scene {
|
||||
}
|
||||
|
||||
private warmFirstBattleSceneModule() {
|
||||
if (this.nextScene === 'PrologueVillageScene') {
|
||||
void ensureLazyScene(this.game, 'PrologueVillageScene').catch((error) => {
|
||||
console.warn('Failed to warm the prologue village scene module.', error);
|
||||
});
|
||||
}
|
||||
if (!this.isFirstBattlePrelude()) {
|
||||
return;
|
||||
}
|
||||
@@ -878,6 +886,9 @@ export class StoryScene extends Phaser.Scene {
|
||||
if (this.nextScene === 'BattleScene') {
|
||||
return '전투 배치';
|
||||
}
|
||||
if (this.nextScene === 'PrologueVillageScene') {
|
||||
return '마을로 이동';
|
||||
}
|
||||
if (this.nextScene === 'CampScene') {
|
||||
return '군영으로';
|
||||
}
|
||||
|
||||
@@ -19,18 +19,20 @@ import {
|
||||
saveVisualMotionMode,
|
||||
visualMotionModeLabel
|
||||
} from '../settings/visualMotion';
|
||||
import { prologueDeparturePages, prologueOpeningPages } from '../data/prologueVillage';
|
||||
import { battleIdForCampaignStep, isCampCampaignStep } from '../state/campaignRouting';
|
||||
import { readCampaignBattleResume, type CampaignBattleResume } from '../state/battleSaveStorage';
|
||||
import {
|
||||
hasCampaignSave,
|
||||
listCampaignSaveSlots,
|
||||
loadCampaignState,
|
||||
prologueVillageCampaignTutorialIds,
|
||||
summarizeCampaignProgress,
|
||||
startNewCampaign,
|
||||
type CampaignSaveSlotSummary
|
||||
} from '../state/campaignState';
|
||||
import { palette } from '../ui/palette';
|
||||
import { startLazyScene } from './lazyScenes';
|
||||
import { startLazyScene, type LazySceneKey } from './lazyScenes';
|
||||
|
||||
const fhdUiScale = 1.5;
|
||||
const ui = (value: number) => value * fhdUiScale;
|
||||
@@ -883,6 +885,8 @@ export class TitleScene extends Phaser.Scene {
|
||||
soundDirector.playSelect();
|
||||
startNewCampaign();
|
||||
void this.navigateTo('StoryScene', {
|
||||
pages: prologueOpeningPages(),
|
||||
nextScene: 'PrologueVillageScene',
|
||||
presentationBattleId: 'first-battle-zhuo-commandery',
|
||||
presentationStage: 'story'
|
||||
});
|
||||
@@ -930,6 +934,31 @@ export class TitleScene extends Phaser.Scene {
|
||||
return;
|
||||
}
|
||||
|
||||
if (campaign.step === 'prologue') {
|
||||
await this.navigateTo('StoryScene', {
|
||||
pages: prologueOpeningPages(),
|
||||
nextScene: 'PrologueVillageScene',
|
||||
presentationBattleId: 'first-battle-zhuo-commandery',
|
||||
presentationStage: 'story'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (campaign.step === 'prologue-town') {
|
||||
if (campaign.completedTutorialIds.includes(prologueVillageCampaignTutorialIds.complete)) {
|
||||
await this.navigateTo('StoryScene', {
|
||||
pages: prologueDeparturePages(),
|
||||
nextScene: 'BattleScene',
|
||||
nextSceneData: { battleId: 'first-battle-zhuo-commandery' },
|
||||
presentationBattleId: 'first-battle-zhuo-commandery',
|
||||
presentationStage: 'story'
|
||||
});
|
||||
} else {
|
||||
await this.navigateTo('PrologueVillageScene');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const battleId = battleIdForCampaignStep(campaign.step);
|
||||
if (battleId) {
|
||||
if (campaign.step !== 'first-battle' && campaign.firstBattleReport?.outcome === 'victory') {
|
||||
@@ -956,12 +985,14 @@ export class TitleScene extends Phaser.Scene {
|
||||
}
|
||||
|
||||
await this.navigateTo('StoryScene', {
|
||||
pages: prologueOpeningPages(),
|
||||
nextScene: 'PrologueVillageScene',
|
||||
presentationBattleId: 'first-battle-zhuo-commandery',
|
||||
presentationStage: 'story'
|
||||
});
|
||||
}
|
||||
|
||||
private async navigateTo(sceneKey: 'StoryScene' | 'BattleScene' | 'CampScene' | 'EndingScene', data?: Record<string, unknown>) {
|
||||
private async navigateTo(sceneKey: LazySceneKey, data?: Record<string, unknown>) {
|
||||
if (this.navigating) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import Phaser from 'phaser';
|
||||
|
||||
export type LazySceneKey = 'StoryScene' | 'BattleScene' | 'CampScene' | 'EndingScene';
|
||||
export type LazySceneKey =
|
||||
| 'StoryScene'
|
||||
| 'PrologueVillageScene'
|
||||
| 'BattleScene'
|
||||
| 'CampScene'
|
||||
| 'EndingScene';
|
||||
|
||||
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),
|
||||
BattleScene: () => import('./BattleScene').then((module) => module.BattleScene),
|
||||
CampScene: () => import('./CampScene').then((module) => module.CampScene),
|
||||
EndingScene: () => import('./EndingScene').then((module) => module.EndingScene)
|
||||
@@ -14,7 +20,13 @@ const lazySceneLoaders: Record<LazySceneKey, () => Promise<LazySceneConstructor>
|
||||
const pendingSceneLoads = new Map<LazySceneKey, Promise<void>>();
|
||||
|
||||
export function isLazySceneKey(key: string): key is LazySceneKey {
|
||||
return key === 'StoryScene' || key === 'BattleScene' || key === 'CampScene' || key === 'EndingScene';
|
||||
return (
|
||||
key === 'StoryScene' ||
|
||||
key === 'PrologueVillageScene' ||
|
||||
key === 'BattleScene' ||
|
||||
key === 'CampScene' ||
|
||||
key === 'EndingScene'
|
||||
);
|
||||
}
|
||||
|
||||
export async function ensureLazyScene(game: Phaser.Game, key: LazySceneKey) {
|
||||
|
||||
@@ -171,6 +171,7 @@ export type CampaignVictoryRewardPresentation = {
|
||||
export type CampaignStep =
|
||||
| 'new'
|
||||
| 'prologue'
|
||||
| 'prologue-town'
|
||||
| 'first-battle'
|
||||
| 'first-camp'
|
||||
| 'first-victory-story'
|
||||
@@ -461,7 +462,18 @@ export type CampaignSortieOrderHistory = Partial<
|
||||
Record<BattleScenarioId, Partial<Record<CampaignSortiePresetId, CampaignSortieOrderResultSnapshot>>>
|
||||
>;
|
||||
|
||||
export const campaignTutorialIds = ['first-battle-basic-controls'] as const;
|
||||
export const prologueVillageCampaignTutorialIds = {
|
||||
entered: 'prologue-village-entered',
|
||||
meetZhangFei: 'prologue-village-meet-zhang-fei',
|
||||
meetGuanYu: 'prologue-village-meet-guan-yu',
|
||||
checkSupplies: 'prologue-village-check-supplies',
|
||||
complete: 'prologue-village-complete'
|
||||
} as const;
|
||||
|
||||
export const campaignTutorialIds = [
|
||||
'first-battle-basic-controls',
|
||||
...Object.values(prologueVillageCampaignTutorialIds)
|
||||
] as const;
|
||||
export type CampaignTutorialId = (typeof campaignTutorialIds)[number];
|
||||
const campaignTutorialIdSet = new Set<string>(campaignTutorialIds);
|
||||
|
||||
@@ -574,6 +586,7 @@ const campaignBattleSteps: Record<string, { victory: CampaignStep; retry: Campai
|
||||
const campaignStepIds = new Set<CampaignStep>([
|
||||
'new',
|
||||
'prologue',
|
||||
'prologue-town',
|
||||
'first-victory-story',
|
||||
'ending-complete',
|
||||
'hanzhong-king-camp',
|
||||
@@ -603,6 +616,7 @@ const campaignRecruitUnitById = new Map(campaignRecruitUnits.map((unit) => [unit
|
||||
const campaignStepProgressLabels: Partial<Record<CampaignStep, { title: string; meta: string }>> = {
|
||||
new: { title: '새 캠페인', meta: '시작 전' },
|
||||
prologue: { title: '도원 결의', meta: '프롤로그' },
|
||||
'prologue-town': { title: '탁현에서 모인 뜻', meta: '마을 탐색 · 출전 준비' },
|
||||
'first-victory-story': { title: '탁현 방어 성공', meta: '승리 후 이야기' },
|
||||
'hanzhong-king-camp': { title: '한중왕 즉위 준비', meta: '군영 의정' },
|
||||
'shu-han-foundation-camp': { title: '촉한 건국 선포', meta: '군영 의정' },
|
||||
|
||||
Reference in New Issue
Block a user