feat: add playable Zhuo prologue village

This commit is contained in:
2026-07-24 01:33:36 +09:00
parent b7c90c9452
commit df1322b1f0
17 changed files with 2418 additions and 40 deletions

View 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));
}

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -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 '군영으로';
}

View File

@@ -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;
}

View File

@@ -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) {

View File

@@ -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: '군영 의정' },