feat: connect prologue choices to first battle

This commit is contained in:
2026-07-27 03:21:37 +09:00
parent 40dca5321e
commit a726ba129f
10 changed files with 1437 additions and 60 deletions

View File

@@ -0,0 +1,141 @@
# 게임 디자인 개선 목표와 운영 원칙
작성일: 2026-07-27
## 목표
`heros_web`를 단순히 콘텐츠가 많은 삼국지 게임이 아니라, 다음 행동이
궁금하고 거점에서 내린 결정이 전투와 동료 관계에서 되돌아오는 전략
RPG로 다듬는다.
개선은 개별 아이디어를 임의로 추가하는 방식이 아니라 아래 순환으로
진행한다.
1. 실제 플레이 흐름과 자동 검증 결과를 감사한다.
2. 검증된 게임 디자인 원칙과 비교한다.
3. 체감 효과, 서사 적합성, 구현 위험으로 우선순위를 정한다.
4. 한 번에 검증 가능한 개선 묶음을 구현한다.
5. 1920x1080, DPR 1, 브라우저 확대 100%에서 전체 흐름을 재검증한다.
6. Gitea와 운영본에 반영하고 다음 묶음의 기준선을 갱신한다.
## 프로젝트의 핵심 기둥
### 1. 설명보다 행동
초반 학습은 긴 설명창이 아니라 이동, 대화, 준비, 첫 전투의 실제 행동으로
이뤄져야 한다. 새 규칙은 한 번에 하나씩 소개하고, 플레이어가 직접 한 번
수행한 뒤 결과를 즉시 보여 준다.
### 2. 거점과 전투의 왕복
마을·막사에서 얻은 정보, 정한 군령, 선택한 동료와 장비는 다음 전투의
목표·배치·정보·보상에 반영되어야 한다. 전투 결과도 다시 거점의 대사,
공명, 상품, 인물 상태를 바꿔야 한다.
### 3. 역사적 결과보다 플레이어의 관점
유비의 큰 역사 흐름은 유지한다. 선택은 역사적 사건을 무작정 갈라놓기보다
유비가 무엇을 우선했는지, 관우와 장비가 그 결정을 어떻게 기억하는지,
어떤 전술 준비를 했는지를 바꾼다.
### 4. 인물별 가치관이 있는 공명
공명은 정답 대사를 골라 숫자를 채우는 호감도가 아니다. 관우, 장비,
제갈량을 비롯한 동료는 각자의 가치관으로 같은 결정에 다르게 반응해야
한다. 관계 변화는 대사뿐 아니라 원호, 추격, 엄호, 조언 같은 전장 행동으로
보여 준다.
### 5. 읽을 수 있는 전술
위험, 적 의도, 예상 결과, 지형 효과와 다음 행동을 선택 전에 이해할 수
있어야 한다. 어려움은 정보 은폐나 조작 마찰이 아니라 여러 타당한 선택
사이의 긴장에서 나와야 한다.
### 6. 회수 밀도가 높은 전개
필수 대화 한 장면에는 새 인물 정보, 가치관, 다음 행동 단서를 각각 하나
이상 넣되 중복 설명은 피한다. 중요한 선택은 즉시 인물 반응을 보여 주고,
늦어도 다음 전투 3턴 안에 기능이나 후속 대사로 다시 회수한다.
## 1차 우선순위: 초반 30분
초반의 현재 강점은 유비·관우·장비의 만남을 직접 이동하며 경험하고,
막사에서 정찰·대열·보급을 확인한 뒤 전투로 이어진다는 점이다. 가장 큰
약점은 첫 전투 직전까지 플레이어가 이야기의 진행 순서를 따를 뿐,
자신의 전술적 우선순위를 결정하지 못한다는 점이다.
첫 개선 묶음은 다음 조건을 만족해야 한다.
- 추정의 출전 명령에서 첫 군령을 직접 선택한다.
- 선택지는 기존 `정예`, `기동`, `공성` 전투 평가 체계와 연결한다.
- 선택 전에 달성 조건과 보상을 명확히 보여 준다.
- 초반 맥락에 맞춰 `공성``거점`으로 풀어 쓰고 실패해도 기본 보상에는
불이익이 없음을 알린다.
- 카드를 비교한 뒤 확정하며, 확정 전에는 선택을 자유롭게 바꿀 수 있다.
- 관우나 장비가 선택의 성격에 맞게 즉시 반응한다.
- 저장 후 다시 시작해도 선택이 유지된다.
- 첫 전투 HUD, 평가와 보상이 같은 선택을 사용한다.
- 기존 대사를 줄여 선택 추가가 전체 클릭 수 증가로 이어지지 않게 한다.
- 동료 합류 이동 중 들어온 첫 이동 입력을 보존하고, 목표 NPC 한 번 클릭이
장애물을 우회해 대화 거리까지 이어지게 한다.
- 내레이터 대사에는 빈 초상 틀을 남기지 않고 본문 폭을 넓힌다.
후속 묶음에서는 막사 준비가 화면과 전투에서 더 직접 보이게 한다.
- 관우의 정찰 완료: 작전판과 첫 전투 적 정보 변화
- 장비의 대열 정비 완료: 흩어진 의병의 실제 집결 연출
- 군수관 점검 완료: 보급 수레와 첫 전투 사용 물자 변화
- 젊은 의병 격려: 첫 전투 중 유비의 말을 기억하는 짧은 반응
## 장기 개선 순서
1. **초반 30분**: 가치 선택, 준비의 전투 회수, 대화 밀도와 길 찾기
2. **전투 의사결정**: 적 의도, 피해 예측, 지형·공명·군령의 비교 가능성
3. **체류와 동료**: 인물별 공명 반응, 선택 기억, 장비·정보·대화의 왕복
4. **편성과 성장**: 새 무장이 합류할수록 역할과 조합 선택이 실제로 달라짐
5. **캠페인 리듬**: 전투·탐색·휴식·중대 사건의 강약과 반복 억제
6. **시청각·접근성**: 중요도별 연출, 읽기 속도, 재확인, 입력과 색상 대안
7. **성능·용량**: 자산 예산, 로더 수명, 운영 배포 회귀 검증
## 초기 성공 기준
- 시작 후 60초 안에 직접 조작 가능
- 장비 첫 만남 3분 이내, 세 사람 합류 8분 이내
- 첫 전투 시작 중앙값 16분 이내, 상위 90%도 20분 이내
- 다음 장소를 45초 안에 찾는 사용자 85% 이상
- 30초 이상 방향을 잃는 사용자 10% 미만
- 세 인물의 합류 이유를 각각 설명하는 사용자 85% 이상
- 막사 준비 효과를 하나 이상 설명하는 사용자 80% 이상
- 모든 중요 선택에 즉시 반응과 다음 전투 후속 반응 존재
- 특정 선택지가 의도 없이 70% 이상으로 쏠리지 않음
- 프롤로그 중도 이탈 10% 미만
자동 검증에서는 다음을 대체 지표로 사용한다.
- 필수 대화의 최대 연속 줄 수와 총 진행 횟수
- 선택 전 조건·보상 표시 여부
- 선택 저장·복원과 전투 상태 전달
- 장면별 막힘 없는 이동, 입력 차단과 재개
- 1920x1080 UI 경계, 겹침, 가독성
- 로컬과 운영본의 동일 릴리스 및 전체 프롤로그 통과
## 조사 근거
- [Plants vs. Zombies 튜토리얼 설계, GDC 정리](https://www.gamedeveloper.com/design/gdc-2012-10-tutorial-tips-from-i-plants-vs-zombies-i-creator-george-fan):
행동으로 가르치기, 적은 단어, 비차단 안내, 점진적 규칙 공개
- [Nintendo: Tears of the Kingdom 개발자 인터뷰](https://www.nintendo.com/en-gb/News/2023/May/Ask-the-Developer-Vol-9-The-Legend-of-Zelda-Tears-of-the-Kingdom-Chapter-3-2383821.html):
시야 안의 단서가 다음 발견을 부르는 탐험과 시각적 잡음 억제
- [Outer Wilds: The Intentionality of Wandering](https://www.mobiusdigitalgames.com/news/the-intentionality-of-wandering):
자유 탐험에서도 다음 호기심을 만드는 정보 연결
- [Final Fantasy XV 동료 설계, GDC](https://www.gdcvault.com/play/1025442/Walk-Tall-My-Friends-Giving):
컷신 밖의 이동과 행동으로 동료 성격 전달
- [Fire Emblem Engage 개발자 인터뷰](https://www.nintendo.com/us/whatsnew/ask-the-developer-vol-8-fire-emblem-engage-part-3/):
관계 대화와 전술 기능의 결합
- [Fallout: New Vegas 선택 구조, GDC](https://www.gdcvault.com/play/1015758/Do-%28Say%29-The-Right-Thing):
대화 선택을 플레이어 표현과 실제 결과로 연결
- [Dragon Age 동료 관계와 성장, BioWare](https://www.ea.com/en/games/dragon-age/dragon-age-the-veilguard/news/combat-and-progression):
대화·결정·동료 활동을 유대와 기능 변화로 연결
- [X-COM 고전 회고, GDC](https://www.gdcvault.com/play/1019056/Classic-Game-Postmortem-X-COM):
전략층과 전술층이 서로 의미를 만드는 이중 구조
- [Into the Breach 디자인 회고, GDC](https://www.gdcvault.com/play/1025772/-Into-the-Breach-Design):
명확한 정보와 제한된 규칙으로 깊은 전술 선택 만들기

View File

@@ -125,6 +125,11 @@ try {
false,
'Narrative context should not impersonate a character portrait.'
);
assert.equal(
(await readVillage(page)).dialogue.portraitFrameVisible,
false,
'Narrative context must hide the empty portrait frame instead of resembling a missing asset.'
);
assert.equal(
(await readVillage(page)).dialogue.totalLines,
1,
@@ -169,7 +174,24 @@ try {
walkingZhangFei.x > 360 && walkingZhangFei.x < 1000,
`Zhang Fei should move progressively instead of teleporting: ${JSON.stringify(walkingZhangFei)}`
);
const queuedGuanTarget = villageNpc(village, 'guan-yu');
await page.mouse.click(queuedGuanTarget.x, queuedGuanTarget.y);
await page.waitForFunction(() => {
const state = window.__HEROS_DEBUG__?.village?.();
return (
state?.movement?.queuedIntent?.kind === 'pointer' &&
state?.narrativeMovement?.playerInputDeferred === true
);
});
await waitForVillageNpcPosition(page, 'zhang-fei', { x: 1000, y: 540 });
await page.waitForFunction(() => {
const state = window.__HEROS_DEBUG__?.village?.();
return (
state?.movement?.appliedQueuedIntentCount >= 1 &&
state?.movement?.targetNpcId === 'guan-yu'
);
});
await waitForVillageInteractionTarget(page, 'guan-yu');
village = await readVillage(page);
const tavernZhangFei = villageNpc(village, 'zhang-fei');
assert.equal(tavernZhangFei.moving, false);
@@ -237,12 +259,24 @@ try {
walkingZhangToRecruitment.y > 540 && walkingZhangToRecruitment.y < 805,
`Zhang Fei should move progressively toward registration: ${JSON.stringify(walkingZhangToRecruitment)}`
);
const queuedClerkTarget = villageNpc(village, 'recruiting-clerk');
await page.mouse.click(queuedClerkTarget.x, queuedClerkTarget.y);
await page.waitForFunction(() => {
const state = window.__HEROS_DEBUG__?.village?.();
return (
state?.movement?.queuedIntent?.kind === 'pointer' &&
state?.narrativeMovement?.playerInputDeferred === true
);
});
await waitForVillageNarrativeMovement(page);
await page.waitForFunction(() => (
window.__HEROS_DEBUG__?.village?.()?.movement?.targetNpcId === 'recruiting-clerk'
));
await waitForVillageInteractionTarget(page, 'recruiting-clerk');
village = await readVillage(page);
assertNpcPosition(village, 'guan-yu', { x: 850, y: 655 }, 'recruitment Guan Yu');
assertNpcPosition(village, 'zhang-fei', { x: 810, y: 805 }, 'recruitment Zhang Fei');
await teleportTo(page, 'recruiting-clerk');
await page.keyboard.press('e');
await page.waitForFunction(() => (
window.__HEROS_DEBUG__?.village?.()?.dialogue?.sourceNpcId === 'recruiting-clerk'
@@ -453,7 +487,7 @@ try {
window.__HEROS_DEBUG__?.militiaCamp?.()?.dialogue?.sourceNpcId === 'zou-jing'
));
const commandCamp = await readMilitiaCamp(page);
assert.equal(commandCamp.dialogue.totalLines, 5);
assert.equal(commandCamp.dialogue.totalLines, 1);
assert.equal(commandCamp.dialogue.portraitTextureKey, 'portrait-zou-jing-yellow-turban');
assert.deepEqual(
commandCamp.npcs
@@ -468,6 +502,88 @@ try {
assertBoundsInsideViewport(commandCamp.dialogue.bounds, 'final command dialogue');
await captureStableScreenshot(page, 'dist/verification-prologue-militia-camp-command.png');
await advanceMilitiaCampDialogueUntilClosed(page);
await page.waitForFunction(() => (
window.__HEROS_DEBUG__?.militiaCamp?.()?.commandChoice?.open === true &&
window.__HEROS_DEBUG__?.militiaCamp?.()?.commandChoice?.ready === true
));
let commandChoice = (await readMilitiaCamp(page)).commandChoice;
assert.equal(commandChoice.open, true);
assert.equal(commandChoice.selectedId, null);
assert.equal(commandChoice.pendingId, null);
assert.equal(commandChoice.confirmEnabled, false);
assert.deepEqual(
commandChoice.cards.map((card) => card.orderId),
['elite', 'mobile', 'siege']
);
assertBoundsInsideViewport(commandChoice.panelBounds, 'first command panel');
commandChoice.cards.forEach((card) => {
assert(card.title.length > 0);
assert(card.condition.length > 0);
assert(card.rewardGold > 0);
assert(card.rewardItems.length > 0);
assertBoundsInsideViewport(card.bounds, `first command card ${card.orderId}`);
});
assertBoundsInsideViewport(commandChoice.confirmBounds, 'first command confirm button');
assertBoundsDoNotOverlap(
commandChoice.cards.map((card) => ({ label: card.orderId, bounds: card.bounds })),
'first command cards'
);
await page.keyboard.press('1');
await page.waitForFunction(() => (
window.__HEROS_DEBUG__?.militiaCamp?.()?.commandChoice?.pendingId === 'elite'
));
commandChoice = (await readMilitiaCamp(page)).commandChoice;
assert.equal(commandChoice.selectedId, null, 'Previewing a command must not persist it before confirmation.');
assert.equal(commandChoice.confirmEnabled, true);
await page.waitForTimeout(160);
await page.keyboard.down('2');
await page.waitForTimeout(80);
await page.keyboard.up('2');
await page.waitForFunction(() => (
window.__HEROS_DEBUG__?.militiaCamp?.()?.commandChoice?.pendingId === 'mobile'
));
commandChoice = (await readMilitiaCamp(page)).commandChoice;
assert.equal(
commandChoice.cards.find((card) => card.orderId === 'mobile')?.pending,
true,
'The player must be able to change the previewed command before confirmation.'
);
const siegeCardBounds = commandChoice.cards.find((card) => card.orderId === 'siege')?.bounds;
assert(siegeCardBounds, 'The siege command card bounds are required for pointer selection.');
await page.mouse.click(
siegeCardBounds.x + siegeCardBounds.width / 2,
siegeCardBounds.y + siegeCardBounds.height / 2
);
await page.waitForFunction(() => (
window.__HEROS_DEBUG__?.militiaCamp?.()?.commandChoice?.pendingId === 'siege'
));
const mobileCardBounds = commandChoice.cards.find((card) => card.orderId === 'mobile')?.bounds;
assert(mobileCardBounds, 'The mobile command card bounds are required for pointer selection.');
await page.mouse.click(
mobileCardBounds.x + mobileCardBounds.width / 2,
mobileCardBounds.y + mobileCardBounds.height / 2
);
await page.waitForFunction(() => (
window.__HEROS_DEBUG__?.militiaCamp?.()?.commandChoice?.pendingId === 'mobile'
));
await captureStableScreenshot(page, 'dist/verification-prologue-militia-camp-command-choice.png');
await page.keyboard.press('Enter');
await page.waitForFunction(() => (
window.__HEROS_DEBUG__?.militiaCamp?.()?.dialogue?.sourceNpcId === 'first-command-mobile'
));
const commandReaction = await readMilitiaCamp(page);
assert.equal(commandReaction.commandChoice.open, false);
assert.equal(commandReaction.dialogue.totalLines, 3);
assert.equal(commandReaction.dialogue.portraitTextureKey, 'portrait-zhang-fei-yellow-turban');
const savedCommand = await readCampaignSave(page);
assert.deepEqual(savedCommand.sortieOrderSelection, {
battleId: 'first-battle-zhuo-commandery',
orderId: 'mobile'
});
await captureStableScreenshot(page, 'dist/verification-prologue-militia-camp-command-reaction.png');
await advanceMilitiaCampDialogueUntilClosed(page);
await waitForStoryReady(page);
const departure = await readStory(page);
@@ -482,6 +598,10 @@ try {
);
assert(savedDeparture.completedTutorialIds.includes('prologue-camp-complete'));
assert(!savedDeparture.completedTutorialIds.includes('first-battle-basic-controls'));
assert.deepEqual(savedDeparture.sortieOrderSelection, {
battleId: 'first-battle-zhuo-commandery',
orderId: 'mobile'
});
await page.mouse.click(960, 850);
await page.waitForFunction(() => {
@@ -501,6 +621,9 @@ try {
const battleSave = await readCampaignSave(page);
assert.equal(battleSave.step, 'first-battle');
assert(!battleSave.completedTutorialIds.includes('first-battle-basic-controls'));
const firstBattle = await page.evaluate(() => window.__HEROS_DEBUG__?.battle?.());
assert.equal(firstBattle.sortieOrder.selectedId, 'mobile');
assert.equal(firstBattle.sortieOrder.live.orderId, 'mobile');
await seedLegacyCompletedVillageSave(page, battleSave);
await page.reload({ waitUntil: 'domcontentloaded', timeout: 90000 });
@@ -682,6 +805,17 @@ async function waitForVillageNarrativeMovement(page) {
}, undefined, { timeout: 6000 });
}
async function waitForVillageInteractionTarget(page, targetId) {
await page.waitForFunction((expectedTargetId) => {
const village = window.__HEROS_DEBUG__?.village?.();
return (
village?.interaction?.targetId === expectedTargetId &&
village?.interaction?.canInteract === true &&
village?.movement?.target === null
);
}, targetId, { timeout: 9000 });
}
async function advanceDialogueUntilClosed(page) {
for (let attempt = 0; attempt < 12; attempt += 1) {
const active = (await readVillage(page))?.dialogue?.active;
@@ -805,6 +939,25 @@ function assertNpcTextureKeys(npcs, expectedTextureKeyByNpcId, sceneLabel) {
);
}
function assertBoundsDoNotOverlap(entries, label) {
for (let leftIndex = 0; leftIndex < entries.length; leftIndex += 1) {
for (let rightIndex = leftIndex + 1; rightIndex < entries.length; rightIndex += 1) {
const left = entries[leftIndex];
const right = entries[rightIndex];
const overlaps = !(
left.bounds.x + left.bounds.width <= right.bounds.x ||
right.bounds.x + right.bounds.width <= left.bounds.x ||
left.bounds.y + left.bounds.height <= right.bounds.y ||
right.bounds.y + right.bounds.height <= left.bounds.y
);
assert(
!overlaps,
`${label}: ${left.label} overlaps ${right.label}: ${JSON.stringify({ left, right })}`
);
}
}
}
function assertExplorationBackground(background, expectedKey) {
assert(background, `Expected ${expectedKey} debug state.`);
assert.equal(background.key, expectedKey);

View File

@@ -29,6 +29,7 @@ const expectedCampMarkerIds = [
'prologue-camp-inspect-arms',
'prologue-camp-complete'
];
const expectedFirstCommandOrderIds = ['elite', 'mobile', 'siege'];
const server = await createServer({
logLevel: 'error',
@@ -39,6 +40,7 @@ const server = await createServer({
try {
const village = await server.ssrLoadModule('/src/game/data/prologueVillage.ts');
const militiaCamp = await server.ssrLoadModule('/src/game/data/prologueMilitiaCamp.ts');
const sortieOrders = await server.ssrLoadModule('/src/game/data/sortieOrders.ts');
const campaign = await server.ssrLoadModule('/src/game/state/campaignState.ts');
const unitAssets = await server.ssrLoadModule('/src/game/data/unitAssets.ts');
const failures = [];
@@ -178,6 +180,58 @@ try {
'militia camp must have Zou Jing as the single final command interaction',
failures
);
const firstCommandChoices = militiaCamp.prologueMilitiaCampCommandChoices;
assertArrayEquals(
firstCommandChoices.map((choice) => choice.orderId),
expectedFirstCommandOrderIds,
'first command choice order',
failures
);
assert(
new Set(firstCommandChoices.map((choice) => choice.orderId)).size ===
expectedFirstCommandOrderIds.length,
'the first command choices must cover three unique sortie orders',
failures
);
for (const choice of firstCommandChoices) {
const definition = sortieOrders.sortieOrderDefinition(choice.orderId);
assert(
definition.id === choice.orderId &&
definition.rewardGold > 0 &&
definition.rewardItems.length > 0,
`first command ${choice.orderId} must reuse a complete battle order definition`,
failures
);
assert(
choice.label === sortieOrders.sortieOrderDisplayLabel(
'first-battle-zhuo-commandery',
choice.orderId
) &&
choice.lead.length >= 8 &&
choice.battlefieldPromise.length >= 20 &&
choice.firstBattleCondition.length >= 20,
`first command ${choice.orderId} must explain its player value and battlefield promise`,
failures
);
assert(
choice.reaction.length === 3 &&
choice.reaction.some((line) => line.speaker === '관우') &&
choice.reaction.some((line) => line.speaker === '장비'),
`first command ${choice.orderId} must receive distinct Guan Yu and Zhang Fei reactions`,
failures
);
verifyDialogueLines(
choice.reaction,
`militia camp/first-command/${choice.orderId}`,
unitAssets,
failures
);
}
assert(
campNpcs.find((npc) => npc.id === 'zou-jing')?.dialogue.length === 1,
'Zou Jing command setup should be compressed to one line so the new decision adds no extra input',
failures
);
assert(
campNpcs.some((npc) => !npc.objectiveId && !npc.departure),
'militia camp should include at least one optional volunteer conversation',
@@ -234,7 +288,7 @@ try {
console.log(
`Verified ${npcs.length} village NPCs, ${campNpcs.length} militia camp NPCs, ` +
`${openingPages.length + brotherhoodPages.length + departurePages.length} prologue story pages, ` +
'sequential meetings, camp preparation, and save markers.'
`${firstCommandChoices.length} first-command choices, sequential meetings, camp preparation, and save markers.`
);
} finally {
await server.close();

View File

@@ -1,4 +1,5 @@
import type { PrologueVillageDialogueLine } from './prologueVillage';
import type { SortieOrderId } from './sortieOrders';
export const prologueMilitiaCampId = 'zhuo-volunteer-camp';
@@ -11,6 +12,87 @@ export const prologueMilitiaCampRequiredObjectiveIds = [
export type PrologueMilitiaCampRequiredObjectiveId =
(typeof prologueMilitiaCampRequiredObjectiveIds)[number];
export type PrologueMilitiaCampCommandChoice = {
orderId: SortieOrderId;
label: string;
lead: string;
battlefieldPromise: string;
firstBattleCondition: string;
reaction: PrologueVillageDialogueLine[];
};
export const prologueMilitiaCampCommandChoices: readonly PrologueMilitiaCampCommandChoice[] = [
{
orderId: 'elite',
label: '정예',
lead: '사람을 잃지 않는 승리',
battlefieldPromise: '전열을 지키고 세 형제가 모두 귀환하는 완성도 높은 승리를 노린다.',
firstBattleCondition: '승리 · 세 형제 전원 생존 · 편성 평가 A 이상',
reaction: [
{
speaker: '관우',
textureKey: 'unit-guan-yu',
text: '옳은 판단입니다. 제가 숲길 전열을 붙들어 의병들이 흩어지지 않게 하겠습니다.'
},
{
speaker: '장비',
textureKey: 'unit-zhang-fei',
text: '좋소! 앞장서되 혼자 깊이 들지는 않겠소. 모두 함께 돌아오는 첫 승리를 만들겠소!'
},
{
speaker: '군령',
text: '정예 군령이 기록되었다. 첫 전투에서 전원 생존과 높은 편성 평가를 달성하면 추가 전공을 얻는다.'
}
]
},
{
orderId: 'mobile',
label: '기동',
lead: '속도를 살린 선봉 돌파',
battlefieldPromise: '장비의 남쪽 압박으로 적의 의도를 먼저 꺾고 제한 턴 안에 승리한다.',
firstBattleCondition: '승리 · 15턴 안에 끝내기 · 장비가 한 번 이상 공격에 기여하기',
reaction: [
{
speaker: '장비',
textureKey: 'unit-zhang-fei',
text: '기다리던 명령이오! 남쪽 길을 단숨에 흔들어 적이 진형을 갖추기 전에 숨통을 끊겠소!'
},
{
speaker: '관우',
textureKey: 'unit-guan-yu',
text: '속도에 휩쓸려 대열이 갈라지지 않도록 중앙에서 장비의 돌파를 받치겠습니다.'
},
{
speaker: '군령',
text: '기동 군령이 기록되었다. 첫 전투에서 제한 턴과 돌파 기여 조건을 달성하면 추가 전공을 얻는다.'
}
]
},
{
orderId: 'siege',
label: '거점',
lead: '마을 어귀까지 확실히 구원',
battlefieldPromise: '두령만 쫓지 않고 마을 거점과 전열·후원의 역할까지 완수한다.',
firstBattleCondition: '승리 · 마을 어귀 확보 · 관우가 앞을 지키고 유비가 책략·회복으로 돕기',
reaction: [
{
speaker: '관우',
textureKey: 'unit-guan-yu',
text: '백성을 두고 두령만 쫓을 수는 없습니다. 제가 마을 어귀의 길목부터 확보하겠습니다.'
},
{
speaker: '장비',
textureKey: 'unit-zhang-fei',
text: '좋소! 관우가 전열을 열면 내가 측면을 걷어 내 형님의 후원이 닿게 하겠소!'
},
{
speaker: '군령',
text: '거점 군령이 기록되었다. 첫 전투에서 마을 목표와 전열·후원 기여를 달성하면 추가 전공을 얻는다.'
}
]
}
];
export const prologueMilitiaCampObjectiveDefinitions: ReadonlyArray<{
id: PrologueMilitiaCampRequiredObjectiveId | 'receive-command';
label: string;
@@ -179,26 +261,7 @@ export const prologueMilitiaCampNpcDefinitions: readonly PrologueMilitiaCampNpcD
{
speaker: '추정',
textureKey: 'unit-shu-officer',
text: '정찰과 대열, 보급까지 모두 갖추었군. 황건 두령 한석이 북동쪽 높은 길에서 마을을 노리고 있다. 그대들이 의병 선봉을 맡아 주게.'
},
{
speaker: '관우',
textureKey: 'unit-guan-yu',
text: '맡은 자리를 지키며 형님의 지휘에 따르겠습니다.'
},
{
speaker: '장비',
textureKey: 'unit-zhang-fei',
text: '의병들이 형님 깃발을 보고 한마음으로 움직이게 하겠소!'
},
{
speaker: '유비',
textureKey: 'unit-liu-bei',
text: '세 사람의 뜻을 한 깃발 아래 모아 첫 싸움을 책임지겠습니다. 의용군 전원에게 북문 집결을 알리시오.'
},
{
speaker: '전황',
text: '세 사람이 나란히 막사를 나섰다. 갓 세운 의용군의 첫 깃발이 북문을 향해 움직이기 시작했다.'
text: '정찰과 대열, 보급까지 갖추었군. 황건 두령 한석이 북동쪽에서 마을을 노다. 그대들이 선봉을 맡되, 첫 승리를 어떤 군령으로 이끌지 직접 정하게.'
}
],
lockedDialogue: [

View File

@@ -96,12 +96,7 @@ export const prologueVillageNpcDefinitions: readonly PrologueVillageNpcDefinitio
{
speaker: '장비',
textureKey: 'unit-zhang-fei',
text: '나는 장비라 하오. 이 고을에서 술과 고기를 팔며 장원을 돌보고 있소. 뜻있는 이를 돕고자 모은 재산이 있으니 함께 의병을 일으시다.'
},
{
speaker: '유비',
textureKey: 'unit-liu-bei',
text: '백성을 지키려는 마음이 같으니 더없이 든든하오. 주점에서 사람을 모을 방도를 자세히 의논합시다.'
text: '나는 장비라 하오. 이 고을에서 술과 고기를 팔며 장원을 돌보고 있소. 뜻있는 이를 돕고자 모은 재산이 있으니 함께 의병을 일으키고, 주점에서 사람을 모을 방도도 의논합시다.'
}
],
repeatDialogue: [
@@ -122,14 +117,10 @@ export const prologueVillageNpcDefinitions: readonly PrologueVillageNpcDefinitio
direction: 'west',
objectiveId: 'meet-guan-yu',
dialogue: [
{
speaker: '전황',
text: '손수레를 세운 붉은 얼굴의 나그네가 주점에 들어와, 군문에 자원하러 가는 길이라며 술을 청했다.'
},
{
speaker: '유비',
textureKey: 'unit-liu-bei',
text: '기상이 범상치 않으십니다. 존함과 이곳 군문을 찾은 까닭을 여쭈어도 되겠소?'
text: '손수레를 세우고 주점에 들어오시더니 군문에 자원하러 가는 길이라 하셨소. 기상이 범상치 않으 존함과 연유를 여쭈어도 되겠소?'
},
{
speaker: '관우',
@@ -174,17 +165,12 @@ export const prologueVillageNpcDefinitions: readonly PrologueVillageNpcDefinitio
{
speaker: '탁현 모병 관리',
textureKey: 'unit-shu-officer',
text: '성명과 출신, 의병을 일으키는 뜻을 명부에 남겨 주십시오. 인가가 나면 모인 군마와 병기를 막사에서 정식으로 나누겠습니다.'
text: '성명과 출신, 의병을 일으키는 뜻을 명부에 남겨 주십시오. 복숭아 동산에서 한뜻임을 맹세한 뒤 장비 공의 장원 밖 의용군 막사로 오시면 군마와 병기를 나누겠습니다.'
},
{
speaker: '유비',
textureKey: 'unit-liu-bei',
text: '탁현의 유비, 하동의 관우, 이 고을의 장비요. 위로는 나라의 어려움에 보답하고 아래로는 백성을 편안하게 하고자 모였소.'
},
{
speaker: '탁현 모병 관리',
textureKey: 'unit-shu-officer',
text: '세 분의 뜻을 군후 추정께 보고하겠습니다. 결의를 마친 뒤 장비 공의 장원 밖 의용군 막사로 와 주십시오.'
}
],
lockedDialogue: [

View File

@@ -143,6 +143,20 @@ export function sortieOrderDefinition(orderId: CampaignSortieOrderId) {
return sortieOrderDefinitions.find((definition) => definition.id === orderId) ?? sortieOrderDefinitions[0];
}
export function sortieOrderDisplayLabel(battleId: string, orderId: CampaignSortieOrderId) {
if (battleId === 'first-battle-zhuo-commandery' && orderId === 'siege') {
return '거점';
}
return sortieOrderDefinition(orderId).label;
}
export function sortieOrderDisplaySummary(battleId: string, orderId: CampaignSortieOrderId) {
if (battleId === 'first-battle-zhuo-commandery' && orderId === 'siege') {
return '마을 어귀와 전열·후원 역할을 함께 완수합니다.';
}
return sortieOrderDefinition(orderId).summary;
}
export function sortieOrderRewardClaimId(battleId: string, orderId: CampaignSortieOrderId) {
return `${battleId}:${orderId}`;
}

View File

@@ -119,6 +119,8 @@ import {
evaluateSortieOrder,
evaluateSortieOrderProgress,
sortieOrderDefinition,
sortieOrderDisplayLabel,
sortieOrderDisplaySummary,
sortieOrderRewardClaimId,
type CampaignSortieOrderProgressId,
type CampaignSortieOrderProgressSnapshot,
@@ -14207,11 +14209,11 @@ export class BattleScene extends Phaser.Scene {
}
private sortieOrderLabel(orderId: SortieOrderId) {
return sortieOrderDefinition(orderId).label;
return sortieOrderDisplayLabel(battleScenario.id, orderId);
}
private sortieOrderSummary(orderId: SortieOrderId) {
return sortieOrderDefinition(orderId).summary;
return sortieOrderDisplaySummary(battleScenario.id, orderId);
}
private sortieOrderProgressLabel(progressId: string) {
@@ -14919,7 +14921,9 @@ export class BattleScene extends Phaser.Scene {
{
label: '전투 수입',
value: `군자금 +${this.resultDisplayedGold(outcome)}`,
badge: sortieOrderReward ? `${sortieOrderReward.label} 공훈 포함` : `목표 ${achievedCount}/${objectives.length}`,
badge: sortieOrderReward
? `${this.sortieOrderLabel(sortieOrderReward.id)} 공훈 포함`
: `목표 ${achievedCount}/${objectives.length}`,
icon: 'leadership',
accent: palette.gold
},
@@ -17599,6 +17603,7 @@ export class BattleScene extends Phaser.Scene {
const roleLines = this.sortieOpeningLines();
const title = this.firstPursuitTrinityConfigured() ? '출진 군세 · 도원 삼재진' : '출진 군세';
this.triggerBattleEvent('opening', title, [
`선택 군령 · ${this.sortieOrderLabel(this.launchSortieOrderId)} · ${this.sortieOrderSummary(this.launchSortieOrderId)}`,
...roleLines,
...(this.launchSortieRecommendation
? [`선택 전략 · ${this.launchSortieRecommendation.label} · ${this.launchSortieRecommendation.summary}`]

View File

@@ -7,13 +7,19 @@ import {
type PortraitAssetEntry
} from '../data/portraitAssets';
import {
prologueMilitiaCampCommandChoices,
prologueMilitiaCampNpcDefinitions,
prologueMilitiaCampObjectiveDefinitions,
prologueMilitiaCampRequiredObjectiveIds,
type PrologueMilitiaCampCommandChoice,
type PrologueMilitiaCampNpcDefinition,
type PrologueMilitiaCampRequiredObjectiveId
} from '../data/prologueMilitiaCamp';
import { prologueDeparturePages, type PrologueVillageDialogueLine } from '../data/prologueVillage';
import {
sortieOrderDefinition,
type SortieOrderId
} from '../data/sortieOrders';
import {
ensureExplorationCharacterAnimations,
explorationCharacterAssetInfos,
@@ -30,6 +36,7 @@ import {
hasCompletedCampaignTutorial,
markCampaignStep,
prologueMilitiaCampCampaignTutorialIds,
setCampaignSortieOrderSelection,
type CampaignTutorialId
} from '../state/campaignState';
import { releaseTextureSource } from '../render/loaderLifecycle';
@@ -121,6 +128,13 @@ type ObjectiveRowView = {
location: Phaser.GameObjects.Text;
};
type CommandChoiceView = {
choice: PrologueMilitiaCampCommandChoice;
button: Phaser.GameObjects.Rectangle;
accent: number;
selectLabel: Phaser.GameObjects.Text;
};
const objectiveTutorialIds: Record<PrologueMilitiaCampRequiredObjectiveId, CampaignTutorialId> = {
'review-scout-report': prologueMilitiaCampCampaignTutorialIds.reviewScoutReport,
'ready-vanguard': prologueMilitiaCampCampaignTutorialIds.readyVanguard,
@@ -153,11 +167,19 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
private promptBackground?: Phaser.GameObjects.Rectangle;
private promptText?: Phaser.GameObjects.Text;
private dialoguePanel?: Phaser.GameObjects.Container;
private dialoguePortraitFrame?: Phaser.GameObjects.Rectangle;
private dialoguePortrait?: Phaser.GameObjects.Image;
private dialogueNameText?: Phaser.GameObjects.Text;
private dialogueBodyText?: Phaser.GameObjects.Text;
private dialogueProgressText?: Phaser.GameObjects.Text;
private dialogueState?: DialogueState;
private commandChoicePanel?: Phaser.GameObjects.Container;
private commandChoicePanelBounds?: Phaser.Geom.Rectangle;
private commandChoiceViews: CommandChoiceView[] = [];
private commandChoiceConfirmButton?: Phaser.GameObjects.Rectangle;
private commandChoiceConfirmText?: Phaser.GameObjects.Text;
private pendingCommandOrderId?: SortieOrderId;
private commandChoiceReadyAt = Number.POSITIVE_INFINITY;
private loadingOverlay?: Phaser.GameObjects.Container;
private transitionOverlay?: Phaser.GameObjects.Container;
private completionToast?: Phaser.GameObjects.Container;
@@ -225,6 +247,7 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
this.navigationPending = false;
this.moveTarget = undefined;
this.dialogueState = undefined;
this.closeCommandChoice();
this.stopNpcMovement();
releaseExplorationCharacterTextureKeys(this, characterTextureKeys);
this.releasePresentationTextures();
@@ -260,6 +283,11 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
this.interactionQueued = false;
return;
}
if (this.commandChoicePanel) {
this.stopPlayerMovement();
this.interactionQueued = false;
return;
}
if (this.dialogueState) {
this.stopPlayerMovement();
if (this.consumeInteractionRequest()) {
@@ -325,6 +353,7 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
controls: {
movement: ['WASD', '방향키'],
interact: ['E', 'Space', 'Enter'],
commandChoice: ['1', '2', '3', 'pointer'],
pointerMove: true,
carryoverGuardMs: inputCarryoverGuardMs
},
@@ -381,6 +410,7 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
? this.dialoguePortrait.texture.key
: null,
portraitVisible: this.dialoguePortrait?.visible ?? false,
portraitFrameVisible: this.dialoguePortraitFrame?.visible ?? false,
bounds: dialogueBounds
}
: {
@@ -391,15 +421,50 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
sourceNpcId: null,
portraitTextureKey: null,
portraitVisible: false,
portraitFrameVisible: false,
bounds: null
},
commandChoice: {
open: Boolean(this.commandChoicePanel),
ready: Boolean(this.commandChoicePanel && this.time.now >= this.commandChoiceReadyAt),
selectedId: campaign.sortieOrderSelection?.battleId === 'first-battle-zhuo-commandery'
? campaign.sortieOrderSelection.orderId
: null,
pendingId: this.pendingCommandOrderId ?? null,
confirmEnabled: Boolean(this.pendingCommandOrderId),
confirmBounds: this.commandChoiceConfirmButton
? this.boundsSnapshot(this.commandChoiceConfirmButton.getBounds())
: null,
panelBounds: this.commandChoicePanelBounds
? this.boundsSnapshot(this.commandChoicePanelBounds)
: null,
cards: this.commandChoiceViews.map(({ choice, button }) => {
const definition = sortieOrderDefinition(choice.orderId);
return {
orderId: choice.orderId,
label: choice.label,
title: `${choice.label} 군령`,
condition: choice.firstBattleCondition,
rewardGold: definition.rewardGold,
rewardItems: [...definition.rewardItems],
pending: this.pendingCommandOrderId === choice.orderId,
bounds: this.boundsSnapshot(button.getBounds())
};
})
},
blockers: this.blockers.map((blocker) => this.boundsSnapshot(blocker)),
navigationPending: this.navigationPending
};
}
debugTeleportTo(targetId: string) {
if (!this.ready || this.navigationPending || this.npcMovementPending || this.dialogueState) {
if (
!this.ready ||
this.navigationPending ||
this.npcMovementPending ||
this.dialogueState ||
this.commandChoicePanel
) {
return false;
}
const view = this.npcViews.get(targetId);
@@ -413,7 +478,13 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
}
debugInteractWith(targetId: string) {
if (!this.ready || this.navigationPending || this.npcMovementPending || this.dialogueState) {
if (
!this.ready ||
this.navigationPending ||
this.npcMovementPending ||
this.dialogueState ||
this.commandChoicePanel
) {
return false;
}
const view = this.npcViews.get(targetId);
@@ -438,10 +509,21 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
return false;
}
this.debugCompleteRequiredObjectives();
const selection = getCampaignState().sortieOrderSelection;
if (selection?.battleId !== 'first-battle-zhuo-commandery') {
setCampaignSortieOrderSelection('first-battle-zhuo-commandery', 'elite');
}
this.finishCamp();
return true;
}
debugChooseFirstCommand(orderId: SortieOrderId) {
if (!this.previewFirstCommand(orderId, true)) {
return false;
}
return this.confirmFirstCommand(true);
}
private resetRuntimeState() {
this.backgroundImage = undefined;
this.backgroundFallback = false;
@@ -454,8 +536,16 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
this.npcMovementPending = false;
this.blockers = [];
this.objectiveRows.clear();
this.dialoguePortraitFrame = undefined;
this.dialoguePortrait = undefined;
this.dialogueState = undefined;
this.commandChoicePanel = undefined;
this.commandChoicePanelBounds = undefined;
this.commandChoiceViews = [];
this.commandChoiceConfirmButton = undefined;
this.commandChoiceConfirmText = undefined;
this.pendingCommandOrderId = undefined;
this.commandChoiceReadyAt = Number.POSITIVE_INFINITY;
this.moveTarget = undefined;
this.ready = false;
this.navigationPending = false;
@@ -952,7 +1042,9 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
.setOrigin(0)
.setStrokeStyle(1, 0x546174, 0.7);
const portraitFrame = this.add.rectangle(x + 132, y + 152, 226, 226, 0x0e131a, 0.92)
.setStrokeStyle(2, 0x9e8350, 0.9);
.setStrokeStyle(2, 0x9e8350, 0.9)
.setVisible(false);
this.dialoguePortraitFrame = portraitFrame;
const firstPortraitKey = campDialoguePortraitEntries()
.find(({ textureKey }) => this.textures.exists(textureKey))
?.textureKey ?? '__DEFAULT';
@@ -1010,12 +1102,25 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
this.interactionQueued = true;
});
});
keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.ONE)
.on('down', () => this.chooseFirstCommandByIndex(0));
keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.TWO)
.on('down', () => this.chooseFirstCommandByIndex(1));
keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.THREE)
.on('down', () => this.chooseFirstCommandByIndex(2));
keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.ENTER)
.on('down', () => this.confirmFirstCommand());
keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.ESC)
.on('down', () => this.cancelFirstCommandChoice());
}
this.input.on(Phaser.Input.Events.POINTER_DOWN, (pointer: Phaser.Input.Pointer) => {
if (!this.ready || this.navigationPending || this.time.now < this.inputReadyAt) {
return;
}
if (this.commandChoicePanel) {
return;
}
if (this.dialogueState) {
this.advanceDialogue();
return;
@@ -1210,7 +1315,11 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
return;
}
this.gatherCommandParty(() => {
this.startDialogue(view.definition.dialogue, () => this.finishCamp(), view.definition.id);
this.startDialogue(
view.definition.dialogue,
() => this.openFirstCommandChoice(),
view.definition.id
);
});
return;
}
@@ -1259,12 +1368,18 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
if (this.dialoguePortrait) {
const entry = this.dialoguePortraitEntry(line);
if (entry && this.textures.exists(entry.textureKey)) {
this.dialoguePortraitFrame?.setVisible(true);
this.dialoguePortrait
.setTexture(entry.textureKey)
.setDisplaySize(218, 218)
.setVisible(true);
this.dialogueNameText?.setX(350);
this.dialogueBodyText?.setX(350).setWordWrapWidth(1382, true);
} else {
this.dialoguePortraitFrame?.setVisible(false);
this.dialoguePortrait.setVisible(false);
this.dialogueNameText?.setX(128);
this.dialogueBodyText?.setX(128).setWordWrapWidth(1604, true);
}
}
}
@@ -1318,6 +1433,350 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
this.showCompletionToast(`${objective?.shortLabel ?? '점검'} 완료`);
}
private openFirstCommandChoice() {
if (this.commandChoicePanel || this.navigationPending || this.dialogueState) {
return;
}
this.stopPlayerMovement();
this.promptBackground?.setVisible(false);
this.promptText?.setVisible(false);
const panelX = 100;
const panelY = 90;
const panelWidth = 1720;
const panelHeight = 900;
const cardX = 145;
const cardY = 265;
const cardWidth = 510;
const cardHeight = 530;
const cardGap = 35;
const cardAccents = [0xd8b96f, 0x78b8cf, 0xc98163];
const selectedOrderId = getCampaignState().sortieOrderSelection?.battleId ===
'first-battle-zhuo-commandery'
? getCampaignState().sortieOrderSelection?.orderId
: undefined;
this.pendingCommandOrderId = selectedOrderId;
const objects: Phaser.GameObjects.GameObject[] = [];
const shade = this.add.rectangle(0, 0, sceneWidth, sceneHeight, 0x05070a, 0.8)
.setOrigin(0)
.setInteractive();
const panel = this.add.rectangle(panelX, panelY, panelWidth, panelHeight, 0x111821, 0.995)
.setOrigin(0)
.setStrokeStyle(3, 0xb89454, 0.94);
const title = this.add.text(sceneWidth / 2, panelY + 54, '첫 출전 군령', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '42px',
color: '#f3dda2',
fontStyle: 'bold'
}).setOrigin(0.5, 0);
const subtitle = this.add.text(
sceneWidth / 2,
panelY + 114,
'같은 전투도 무엇을 지키고 증명할지에 따라 목표와 추가 전공이 달라집니다.',
{
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '22px',
color: '#c5ccd3'
}
).setOrigin(0.5, 0);
const saveHint = this.add.text(
sceneWidth / 2,
panelY + 154,
'역사의 결과를 바꾸는 선택이 아니라, 유비가 첫 승리를 이끄는 방식입니다.',
{
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '18px',
color: '#8fa3b8'
}
).setOrigin(0.5, 0);
objects.push(shade, panel, title, subtitle, saveHint);
this.commandChoiceViews = prologueMilitiaCampCommandChoices.map((choice, index) => {
const definition = sortieOrderDefinition(choice.orderId);
const x = cardX + index * (cardWidth + cardGap);
const accent = cardAccents[index] ?? cardAccents[0];
const selected = selectedOrderId === choice.orderId;
const button = this.add.rectangle(
x,
cardY,
cardWidth,
cardHeight,
selected ? 0x263746 : 0x1a2531,
0.995
)
.setOrigin(0)
.setStrokeStyle(selected ? 4 : 2, accent, selected ? 1 : 0.78)
.setInteractive({ useHandCursor: true });
const number = this.add.text(x + 28, cardY + 24, `${index + 1}`, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '29px',
color: '#f3dda2',
fontStyle: 'bold'
});
const label = this.add.text(x + 78, cardY + 25, `${choice.label} 군령`, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '27px',
color: '#f4f0e6',
fontStyle: 'bold'
});
const lead = this.add.text(x + 28, cardY + 82, choice.lead, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '22px',
color: '#f0cf82',
fontStyle: 'bold',
wordWrap: { width: cardWidth - 56, useAdvancedWrap: true }
});
const promise = this.add.text(x + 28, cardY + 126, choice.battlefieldPromise, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '19px',
color: '#d4dce5',
lineSpacing: 7,
wordWrap: { width: cardWidth - 56, useAdvancedWrap: true }
});
const divider = this.add.rectangle(
x + 28,
cardY + 224,
cardWidth - 56,
1,
accent,
0.48
).setOrigin(0);
const conditionTitle = this.add.text(x + 28, cardY + 252, '달성 조건', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '17px',
color: '#93a7b9',
fontStyle: 'bold'
});
const condition = this.add.text(x + 28, cardY + 284, choice.firstBattleCondition, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '19px',
color: '#f0ede5',
lineSpacing: 7,
wordWrap: { width: cardWidth - 56, useAdvancedWrap: true }
});
const rewardTitle = this.add.text(x + 28, cardY + 382, '첫 달성 전공', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '17px',
color: '#93a7b9',
fontStyle: 'bold'
});
const reward = this.add.text(
x + 28,
cardY + 416,
`${definition.rewardGold} · ${definition.rewardItems.join(' · ')}`,
{
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '21px',
color: '#a8d58e',
fontStyle: 'bold',
wordWrap: { width: cardWidth - 56, useAdvancedWrap: true }
}
);
const selectLabel = this.add.text(
x + cardWidth / 2,
cardY + cardHeight - 38,
selected ? '현재 선택 · 다시 선택 가능' : '이 군령으로 출전',
{
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '18px',
color: selected ? '#f3dda2' : '#c3ccd5',
fontStyle: 'bold'
}
).setOrigin(0.5);
button.on('pointerover', () => {
button.setFillStyle(0x2a3c4e, 0.995).setStrokeStyle(4, accent, 1);
});
button.on('pointerout', () => {
this.refreshFirstCommandChoice();
});
button.on('pointerdown', () => this.previewFirstCommand(choice.orderId));
objects.push(
button,
number,
label,
lead,
promise,
divider,
conditionTitle,
condition,
rewardTitle,
reward,
selectLabel
);
return { choice, button, accent, selectLabel };
});
const callback = this.add.text(
sceneWidth / 2,
panelY + panelHeight - 142,
'선택한 군령은 첫 전투 목표판에 이어집니다. 실패해도 전투와 기본 보상에는 불이익이 없습니다.',
{
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '19px',
color: '#d5c59e'
}
).setOrigin(0.5);
const confirmButton = this.add.rectangle(
sceneWidth / 2 - 250,
panelY + panelHeight - 108,
500,
58,
this.pendingCommandOrderId ? 0x806536 : 0x2a3037,
0.99
)
.setOrigin(0)
.setStrokeStyle(2, this.pendingCommandOrderId ? 0xe0bf70 : 0x606b76, 0.9)
.setInteractive({ useHandCursor: true });
const confirmText = this.add.text(
sceneWidth / 2,
panelY + panelHeight - 79,
this.pendingCommandOrderId ? '이 군령으로 출전 확정' : '먼저 군령을 선택하세요',
{
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '21px',
color: this.pendingCommandOrderId ? '#fff0bd' : '#89939e',
fontStyle: 'bold'
}
).setOrigin(0.5);
this.commandChoiceConfirmButton = confirmButton;
this.commandChoiceConfirmText = confirmText;
confirmButton.on('pointerover', () => {
if (this.pendingCommandOrderId) {
confirmButton.setFillStyle(0x9a7940, 1);
}
});
confirmButton.on('pointerout', () => this.refreshFirstCommandChoice());
confirmButton.on('pointerdown', () => this.confirmFirstCommand());
const controls = this.add.text(
sceneWidth / 2,
panelY + panelHeight - 25,
'숫자키 1 · 2 · 3 또는 카드 클릭으로 비교 | Enter 확정 | ESC 막사로 돌아가기',
{
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '17px',
color: '#98a5b2'
}
).setOrigin(0.5);
objects.push(
callback,
confirmButton,
confirmText,
controls
);
this.commandChoicePanel = this.add.container(0, 0, objects).setDepth(3200);
this.commandChoicePanelBounds = new Phaser.Geom.Rectangle(
panelX,
panelY,
panelWidth,
panelHeight
);
this.commandChoiceReadyAt = this.time.now + 260;
this.refreshFirstCommandChoice();
soundDirector.playSelect();
}
private chooseFirstCommandByIndex(index: number) {
const choice = prologueMilitiaCampCommandChoices[index];
if (choice) {
this.previewFirstCommand(choice.orderId);
}
}
private previewFirstCommand(orderId: SortieOrderId, bypassInputGuard = false) {
if (
!this.commandChoicePanel ||
this.navigationPending ||
(!bypassInputGuard && this.time.now < this.commandChoiceReadyAt)
) {
return false;
}
const choice = prologueMilitiaCampCommandChoices.find((entry) => entry.orderId === orderId);
if (!choice) {
return false;
}
this.pendingCommandOrderId = choice.orderId;
this.refreshFirstCommandChoice();
soundDirector.playSelect();
return true;
}
private refreshFirstCommandChoice() {
this.commandChoiceViews.forEach(({ choice, button, accent, selectLabel }) => {
const selected = this.pendingCommandOrderId === choice.orderId;
button
.setFillStyle(selected ? 0x263746 : 0x1a2531, 0.995)
.setStrokeStyle(selected ? 4 : 2, accent, selected ? 1 : 0.78);
selectLabel
.setText(selected ? '선택됨 · 확정 전 변경 가능' : '이 군령을 살펴보기')
.setColor(selected ? '#f3dda2' : '#c3ccd5');
});
const enabled = Boolean(this.pendingCommandOrderId);
this.commandChoiceConfirmButton
?.setFillStyle(enabled ? 0x806536 : 0x2a3037, 0.99)
.setStrokeStyle(2, enabled ? 0xe0bf70 : 0x606b76, 0.9);
this.commandChoiceConfirmText
?.setText(enabled ? '이 군령으로 출전 확정' : '먼저 군령을 선택하세요')
.setColor(enabled ? '#fff0bd' : '#89939e');
}
private confirmFirstCommand(bypassInputGuard = false) {
if (
!this.commandChoicePanel ||
!this.pendingCommandOrderId ||
this.navigationPending ||
(!bypassInputGuard && this.time.now < this.commandChoiceReadyAt)
) {
return false;
}
const choice = prologueMilitiaCampCommandChoices.find(
(entry) => entry.orderId === this.pendingCommandOrderId
);
if (!choice) {
return false;
}
setCampaignSortieOrderSelection('first-battle-zhuo-commandery', choice.orderId);
this.inputReadyAt = this.time.now + 260;
this.closeCommandChoice();
soundDirector.playObjectiveAchieved();
this.startDialogue(
choice.reaction,
() => this.finishCamp(),
`first-command-${choice.orderId}`
);
return true;
}
private cancelFirstCommandChoice() {
if (
!this.commandChoicePanel ||
this.navigationPending ||
this.time.now < this.commandChoiceReadyAt
) {
return false;
}
this.closeCommandChoice();
this.showPromptMessage('추정에게 다시 말을 걸면 첫 출전 군령을 정할 수 있습니다.');
this.refreshInteractionPrompt();
return true;
}
private closeCommandChoice() {
this.commandChoicePanel?.destroy();
this.commandChoicePanel = undefined;
this.commandChoicePanelBounds = undefined;
this.commandChoiceViews = [];
this.commandChoiceConfirmButton = undefined;
this.commandChoiceConfirmText = undefined;
this.pendingCommandOrderId = undefined;
this.commandChoiceReadyAt = Number.POSITIVE_INFINITY;
}
private finishCamp() {
if (this.navigationPending) {
return;
@@ -1329,6 +1788,10 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
}
return;
}
if (getCampaignState().sortieOrderSelection?.battleId !== 'first-battle-zhuo-commandery') {
this.openFirstCommandChoice();
return;
}
completeCampaignTutorial(prologueMilitiaCampCampaignTutorialIds.complete);
this.refreshObjectiveHud();

View File

@@ -48,6 +48,12 @@ const promptRadius = 160;
const characterDisplaySize = 104;
const inputCarryoverGuardMs = 320;
const narrativeNpcSpeed = 300;
const queuedKeyboardMoveDistance = 150;
const navigationArrivalRadius = 8;
const navigationProbeStep = 24;
const navigationDetourClearance = 64;
const objectivePointerSnapRadius = 172;
const directPointerSnapRadius = 92;
const villageBackgroundKey = 'prologue-village-background';
const villageDialoguePortraitContext = {
id: 'prologue-village-dialogue',
@@ -146,6 +152,20 @@ type NarrativeNpcMove = {
};
};
type QueuedMovementIntent =
| {
kind: 'pointer';
x: number;
y: number;
queuedAt: number;
}
| {
kind: 'keyboard';
x: number;
y: number;
queuedAt: number;
};
const objectiveTutorialIds: Record<PrologueVillageRequiredObjectiveId, CampaignTutorialId> = {
'meet-zhang-fei': prologueVillageCampaignTutorialIds.meetZhangFei,
'meet-guan-yu': prologueVillageCampaignTutorialIds.meetGuanYu,
@@ -178,6 +198,7 @@ export class PrologueVillageScene extends Phaser.Scene {
private oathMarker?: Phaser.GameObjects.Text;
private oathLabel?: Phaser.GameObjects.Text;
private dialoguePanel?: Phaser.GameObjects.Container;
private dialoguePortraitFrame?: Phaser.GameObjects.Rectangle;
private dialoguePortrait?: Phaser.GameObjects.Image;
private dialogueNameText?: Phaser.GameObjects.Text;
private dialogueBodyText?: Phaser.GameObjects.Text;
@@ -195,6 +216,17 @@ export class PrologueVillageScene extends Phaser.Scene {
};
private interactKeys: Phaser.Input.Keyboard.Key[] = [];
private moveTarget?: Phaser.Math.Vector2;
private moveWaypoints: Phaser.Math.Vector2[] = [];
private moveTargetNpcId?: string;
private queuedMovementIntent?: QueuedMovementIntent;
private queuedMovementIntentCount = 0;
private appliedQueuedMovementIntentCount = 0;
private lastAppliedQueuedMovementIntent?: {
kind: QueuedMovementIntent['kind'];
appliedAt: number;
};
private navigationDetourCount = 0;
private navigationReplanAttempts = 0;
private npcMoveTweens = new Map<string, Phaser.Tweens.Tween>();
private oathGatherPending = false;
private ready = false;
@@ -252,6 +284,9 @@ export class PrologueVillageScene extends Phaser.Scene {
this.navigationPending = false;
this.oathGatherPending = false;
this.moveTarget = undefined;
this.moveWaypoints = [];
this.moveTargetNpcId = undefined;
this.queuedMovementIntent = undefined;
this.dialogueState = undefined;
this.stopAllNpcMovement();
releaseExplorationCharacterTextureKeys(this, characterTextureKeys);
@@ -301,6 +336,8 @@ export class PrologueVillageScene extends Phaser.Scene {
return;
}
this.applyQueuedMovementIntentIfReady();
if (this.consumeInteractionRequest()) {
this.tryInteract();
return;
@@ -349,6 +386,22 @@ export class PrologueVillageScene extends Phaser.Scene {
movement: {
speed: playerSpeed,
target: this.moveTarget ? { x: this.moveTarget.x, y: this.moveTarget.y } : null,
targetNpcId: this.moveTargetNpcId ?? null,
activeWaypoint: this.moveWaypoints[0]
? { x: this.moveWaypoints[0].x, y: this.moveWaypoints[0].y }
: null,
waypoints: this.moveWaypoints.map(({ x, y }) => ({ x, y })),
queuedIntent: this.queuedMovementIntent
? { ...this.queuedMovementIntent }
: null,
queuedIntentCount: this.queuedMovementIntentCount,
appliedQueuedIntentCount: this.appliedQueuedMovementIntentCount,
lastAppliedQueuedIntent: this.lastAppliedQueuedMovementIntent
? { ...this.lastAppliedQueuedMovementIntent }
: null,
deferredForNarrativeMovement: this.hasNarrativeNpcMovement(),
detourCount: this.navigationDetourCount,
replanAttempts: this.navigationReplanAttempts,
walkBounds: this.boundsSnapshot(movementBounds),
collisionRadius: playerCollisionRadius
},
@@ -411,6 +464,7 @@ export class PrologueVillageScene extends Phaser.Scene {
? this.dialoguePortrait.texture.key
: null,
portraitVisible: this.dialoguePortrait?.visible ?? false,
portraitFrameVisible: this.dialoguePortraitFrame?.visible ?? false,
bounds: dialogueBounds
}
: {
@@ -421,12 +475,15 @@ export class PrologueVillageScene extends Phaser.Scene {
sourceNpcId: null,
portraitTextureKey: null,
portraitVisible: false,
portraitFrameVisible: false,
bounds: null
},
blockers: this.blockers.map((blocker) => this.boundsSnapshot(blocker)),
narrativeMovement: {
movingNpcIds: Array.from(this.npcMoveTweens.keys()),
oathGatherPending: this.oathGatherPending
oathGatherPending: this.oathGatherPending,
playerInputDeferred: this.hasNarrativeNpcMovement() &&
this.queuedMovementIntent !== undefined
},
navigationPending: this.navigationPending,
requiredTextures: characterTextureKeys.map((key) => ({
@@ -489,9 +546,18 @@ export class PrologueVillageScene extends Phaser.Scene {
this.npcViews.clear();
this.blockers = [];
this.objectiveRows.clear();
this.dialoguePortraitFrame = undefined;
this.dialoguePortrait = undefined;
this.dialogueState = undefined;
this.moveTarget = undefined;
this.moveWaypoints = [];
this.moveTargetNpcId = undefined;
this.queuedMovementIntent = undefined;
this.queuedMovementIntentCount = 0;
this.appliedQueuedMovementIntentCount = 0;
this.lastAppliedQueuedMovementIntent = undefined;
this.navigationDetourCount = 0;
this.navigationReplanAttempts = 0;
this.ready = false;
this.navigationPending = false;
this.inputReadyAt = Number.POSITIVE_INFINITY;
@@ -1010,7 +1076,9 @@ export class PrologueVillageScene extends Phaser.Scene {
.setOrigin(0)
.setStrokeStyle(1, 0x546174, 0.7);
const portraitFrame = this.add.rectangle(x + 132, y + 152, 226, 226, 0x0e131a, 0.92)
.setStrokeStyle(2, 0x9e8350, 0.9);
.setStrokeStyle(2, 0x9e8350, 0.9)
.setVisible(false);
this.dialoguePortraitFrame = portraitFrame;
const firstPortraitKey = villageDialoguePortraitEntries()
.find(({ textureKey }) => this.textures.exists(textureKey))
?.textureKey ?? '__DEFAULT';
@@ -1058,6 +1126,18 @@ export class PrologueVillageScene extends Phaser.Scene {
left: keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.A),
right: keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.D)
};
[
{ key: this.cursorKeys.up, x: 0, y: -1 },
{ key: this.cursorKeys.down, x: 0, y: 1 },
{ key: this.cursorKeys.left, x: -1, y: 0 },
{ key: this.cursorKeys.right, x: 1, y: 0 },
{ key: this.moveKeys.up, x: 0, y: -1 },
{ key: this.moveKeys.down, x: 0, y: 1 },
{ key: this.moveKeys.left, x: -1, y: 0 },
{ key: this.moveKeys.right, x: 1, y: 0 }
].forEach(({ key, x, y }) => {
key.on('down', () => this.queueNarrativeKeyboardMovement(x, y));
});
this.interactKeys = [
keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.E),
keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.SPACE),
@@ -1092,7 +1172,16 @@ export class PrologueVillageScene extends Phaser.Scene {
}
if (pointer.x >= movementBounds.left && pointer.x <= movementBounds.right &&
pointer.y >= movementBounds.top && pointer.y <= movementBounds.bottom) {
this.moveTarget = new Phaser.Math.Vector2(pointer.x, pointer.y);
if (this.hasNarrativeNpcMovement()) {
this.queueMovementIntent({
kind: 'pointer',
x: pointer.x,
y: pointer.y,
queuedAt: this.time.now
});
} else {
this.beginPointerMovement(pointer.x, pointer.y);
}
}
});
}
@@ -1102,13 +1191,34 @@ export class PrologueVillageScene extends Phaser.Scene {
return;
}
const keyboardDirection = this.keyboardMovementDirection();
if (this.hasNarrativeNpcMovement()) {
if (keyboardDirection.lengthSq() > 0) {
const normalized = keyboardDirection.normalize();
this.queueMovementIntent({
kind: 'keyboard',
x: normalized.x,
y: normalized.y,
queuedAt: time
});
}
this.setPlayerMoving(false);
return;
}
let movement = keyboardDirection;
if (movement.lengthSq() > 0) {
this.moveTarget = undefined;
this.clearNavigationTarget();
} else if (this.moveTarget) {
const targetDelta = this.moveTarget.clone().subtract(new Phaser.Math.Vector2(this.player.x, this.player.y));
if (targetDelta.length() <= 7) {
this.moveTarget = undefined;
const activeTarget = this.moveWaypoints[0] ?? this.moveTarget;
const targetDelta = activeTarget.clone().subtract(
new Phaser.Math.Vector2(this.player.x, this.player.y)
);
if (targetDelta.length() <= navigationArrivalRadius) {
if (this.moveWaypoints.length > 0) {
this.moveWaypoints.shift();
} else {
this.clearNavigationTarget();
}
movement = new Phaser.Math.Vector2();
} else {
movement = targetDelta.normalize();
@@ -1138,7 +1248,9 @@ export class PrologueVillageScene extends Phaser.Scene {
moved = true;
}
if (!moved && this.moveTarget) {
this.moveTarget = undefined;
if (!this.replanBlockedPointerMovement()) {
this.clearNavigationTarget();
}
}
this.syncPlayerView();
@@ -1155,6 +1267,361 @@ export class PrologueVillageScene extends Phaser.Scene {
}
}
private hasNarrativeNpcMovement() {
return this.npcMoveTweens.size > 0;
}
private queueNarrativeKeyboardMovement(x: number, y: number) {
if (
!this.ready ||
this.navigationPending ||
this.oathGatherPending ||
this.dialogueState ||
this.time.now < this.inputReadyAt ||
!this.hasNarrativeNpcMovement()
) {
return;
}
this.queueMovementIntent({
kind: 'keyboard',
x,
y,
queuedAt: this.time.now
});
}
private queueMovementIntent(intent: QueuedMovementIntent) {
const current = this.queuedMovementIntent;
if (
current?.kind === intent.kind &&
Math.abs(current.x - intent.x) < 0.01 &&
Math.abs(current.y - intent.y) < 0.01
) {
return;
}
this.queuedMovementIntent = intent;
this.queuedMovementIntentCount += 1;
}
private applyQueuedMovementIntentIfReady() {
if (
!this.queuedMovementIntent ||
this.hasNarrativeNpcMovement() ||
this.dialogueState ||
this.oathGatherPending ||
this.navigationPending
) {
return;
}
const intent = this.queuedMovementIntent;
this.queuedMovementIntent = undefined;
this.appliedQueuedMovementIntentCount += 1;
this.lastAppliedQueuedMovementIntent = {
kind: intent.kind,
appliedAt: this.time.now
};
if (intent.kind === 'pointer') {
this.beginPointerMovement(intent.x, intent.y);
return;
}
if (!this.player) {
return;
}
const direction = new Phaser.Math.Vector2(intent.x, intent.y);
if (direction.lengthSq() <= 0) {
return;
}
direction.normalize().scale(queuedKeyboardMoveDistance);
this.beginNavigationTo(
this.player.x + direction.x,
this.player.y + direction.y
);
}
private beginPointerMovement(x: number, y: number) {
const snappedTarget = this.pointerMovementTargetAt(x, y);
if (!snappedTarget) {
this.beginNavigationTo(x, y);
return;
}
const plannedApproaches = this.approachPositions(snappedTarget.x, snappedTarget.y)
.map((destination) => ({
destination,
route: this.planNavigationRoute(destination)
}))
.filter((entry): entry is {
destination: Phaser.Math.Vector2;
route: Phaser.Math.Vector2[];
} => Boolean(entry.route))
.sort((left, right) => (
this.navigationRouteLength(left.route) - this.navigationRouteLength(right.route)
));
const best = plannedApproaches[0];
if (best) {
this.applyNavigationRoute(best.route, snappedTarget.id);
return;
}
const fallback = this.safeApproachPosition(snappedTarget.x, snappedTarget.y);
this.beginNavigationTo(fallback.x, fallback.y, snappedTarget.id);
}
private beginNavigationTo(x: number, y: number, targetNpcId?: string) {
const destination = this.nearestWalkablePoint(x, y);
if (!destination) {
this.clearNavigationTarget();
return;
}
const route = this.planNavigationRoute(destination);
if (!route) {
this.clearNavigationTarget();
return;
}
this.applyNavigationRoute(route, targetNpcId);
}
private applyNavigationRoute(route: Phaser.Math.Vector2[], targetNpcId?: string) {
const destination = route[route.length - 1];
if (!destination) {
this.clearNavigationTarget();
return;
}
this.moveTarget = destination.clone();
this.moveWaypoints = route.slice(0, -1).map((waypoint) => waypoint.clone());
this.moveTargetNpcId = targetNpcId;
this.navigationReplanAttempts = 0;
if (route.length > 1) {
this.navigationDetourCount += 1;
}
}
private pointerMovementTargetAt(x: number, y: number) {
const targets: InteractionTarget[] = [
...Array.from(this.npcViews.values())
.filter(({ definition }) => !this.npcMoveTweens.has(definition.id))
.map(({ definition, sprite }) => ({
kind: 'npc' as const,
id: definition.id,
name: definition.name,
x: sprite.x,
y: sprite.y
})),
{
kind: 'oath' as const,
id: 'make-oath' as const,
name: '도원결의',
x: oathPosition.x,
y: oathPosition.y
}
];
return targets
.map((target) => ({
target,
distance: Phaser.Math.Distance.Between(x, y, target.x, target.y),
priority: this.interactionTargetPriority(target)
}))
.filter(({ distance, priority }) => (
distance <= (priority === 0 ? objectivePointerSnapRadius : directPointerSnapRadius)
))
.sort((left, right) => (
left.priority - right.priority ||
left.distance - right.distance
))[0]?.target;
}
private approachPositions(targetX: number, targetY: number) {
return [
new Phaser.Math.Vector2(targetX, targetY + 88),
new Phaser.Math.Vector2(targetX - 88, targetY),
new Phaser.Math.Vector2(targetX + 88, targetY),
new Phaser.Math.Vector2(targetX, targetY - 88)
].filter(({ x, y }) => this.canPlayerOccupy(x, y));
}
private nearestWalkablePoint(x: number, y: number) {
const margin = playerCollisionRadius + 4;
const clamped = new Phaser.Math.Vector2(
Phaser.Math.Clamp(x, movementBounds.left + margin, movementBounds.right - margin),
Phaser.Math.Clamp(y, movementBounds.top + margin, movementBounds.bottom - margin)
);
if (this.canPlayerOccupy(clamped.x, clamped.y)) {
return clamped;
}
for (const radius of [48, 80, 112, 144]) {
for (let index = 0; index < 8; index += 1) {
const angle = index * Math.PI / 4;
const candidate = new Phaser.Math.Vector2(
Phaser.Math.Clamp(
clamped.x + Math.cos(angle) * radius,
movementBounds.left + margin,
movementBounds.right - margin
),
Phaser.Math.Clamp(
clamped.y + Math.sin(angle) * radius,
movementBounds.top + margin,
movementBounds.bottom - margin
)
);
if (this.canPlayerOccupy(candidate.x, candidate.y)) {
return candidate;
}
}
}
return undefined;
}
private planNavigationRoute(destination: Phaser.Math.Vector2) {
if (!this.player || !this.canPlayerOccupy(destination.x, destination.y)) {
return undefined;
}
const start = new Phaser.Math.Vector2(this.player.x, this.player.y);
if (this.segmentIsNavigable(start, destination)) {
return [destination.clone()];
}
const candidates = this.navigationDetourCandidates(start, destination);
let bestRoute: Phaser.Math.Vector2[] | undefined;
let bestLength = Number.POSITIVE_INFINITY;
const startVisible = candidates.filter((candidate) => (
this.segmentIsNavigable(start, candidate)
));
const destinationVisible = candidates.filter((candidate) => (
this.segmentIsNavigable(candidate, destination)
));
startVisible.forEach((waypoint) => {
if (!this.segmentIsNavigable(waypoint, destination)) {
return;
}
const route = [waypoint, destination];
const length = this.navigationRouteLength(route);
if (length < bestLength) {
bestLength = length;
bestRoute = route;
}
});
startVisible.forEach((first) => {
destinationVisible.forEach((second) => {
if (first.equals(second) || !this.segmentIsNavigable(first, second)) {
return;
}
const route = [first, second, destination];
const length = this.navigationRouteLength(route);
if (length < bestLength) {
bestLength = length;
bestRoute = route;
}
});
});
return bestRoute?.map((waypoint) => waypoint.clone());
}
private navigationDetourCandidates(
start: Phaser.Math.Vector2,
destination: Phaser.Math.Vector2
) {
const margin = playerCollisionRadius + 4;
const candidates = [
new Phaser.Math.Vector2(start.x, destination.y),
new Phaser.Math.Vector2(destination.x, start.y)
];
this.blockers.forEach((blocker) => {
const left = blocker.left - navigationDetourClearance;
const right = blocker.right + navigationDetourClearance;
const top = blocker.top - navigationDetourClearance;
const bottom = blocker.bottom + navigationDetourClearance;
candidates.push(
new Phaser.Math.Vector2(left, top),
new Phaser.Math.Vector2(right, top),
new Phaser.Math.Vector2(left, bottom),
new Phaser.Math.Vector2(right, bottom)
);
});
this.npcViews.forEach(({ sprite }) => {
candidates.push(
new Phaser.Math.Vector2(sprite.x - 72, sprite.y),
new Phaser.Math.Vector2(sprite.x + 72, sprite.y),
new Phaser.Math.Vector2(sprite.x, sprite.y - 72),
new Phaser.Math.Vector2(sprite.x, sprite.y + 72)
);
});
const unique = new Map<string, Phaser.Math.Vector2>();
candidates.forEach((candidate) => {
candidate.set(
Phaser.Math.Clamp(
candidate.x,
movementBounds.left + margin,
movementBounds.right - margin
),
Phaser.Math.Clamp(
candidate.y,
movementBounds.top + margin,
movementBounds.bottom - margin
)
);
if (!this.canPlayerOccupy(candidate.x, candidate.y)) {
return;
}
unique.set(`${Math.round(candidate.x)}:${Math.round(candidate.y)}`, candidate);
});
return Array.from(unique.values());
}
private segmentIsNavigable(
from: Phaser.Math.Vector2,
to: Phaser.Math.Vector2
) {
const distance = Phaser.Math.Distance.Between(from.x, from.y, to.x, to.y);
const steps = Math.max(1, Math.ceil(distance / navigationProbeStep));
for (let index = 1; index <= steps; index += 1) {
const progress = index / steps;
const x = Phaser.Math.Linear(from.x, to.x, progress);
const y = Phaser.Math.Linear(from.y, to.y, progress);
if (!this.canPlayerOccupy(x, y)) {
return false;
}
}
return true;
}
private navigationRouteLength(route: readonly Phaser.Math.Vector2[]) {
if (!this.player) {
return Number.POSITIVE_INFINITY;
}
let previous = new Phaser.Math.Vector2(this.player.x, this.player.y);
return route.reduce((total, waypoint) => {
const length = Phaser.Math.Distance.Between(
previous.x,
previous.y,
waypoint.x,
waypoint.y
);
previous = waypoint;
return total + length;
}, 0);
}
private replanBlockedPointerMovement() {
if (!this.moveTarget || this.navigationReplanAttempts >= 2) {
return false;
}
this.navigationReplanAttempts += 1;
const route = this.planNavigationRoute(this.moveTarget);
if (!route) {
return false;
}
this.moveWaypoints = route.slice(0, -1).map((waypoint) => waypoint.clone());
return true;
}
private clearNavigationTarget() {
this.moveTarget = undefined;
this.moveWaypoints = [];
this.moveTargetNpcId = undefined;
this.navigationReplanAttempts = 0;
}
private consumeInteractionRequest() {
const keyPressedThisFrame = this.interactKeys.some((key) => Phaser.Input.Keyboard.JustDown(key));
const requested = this.interactionQueued || keyPressedThisFrame;
@@ -1221,7 +1688,7 @@ export class PrologueVillageScene extends Phaser.Scene {
}
private stopPlayerMovement() {
this.moveTarget = undefined;
this.clearNavigationTarget();
this.setPlayerMoving(false);
}
@@ -1540,12 +2007,18 @@ export class PrologueVillageScene extends Phaser.Scene {
if (this.dialoguePortrait) {
const entry = this.dialoguePortraitEntry(line);
if (entry && this.textures.exists(entry.textureKey)) {
this.dialoguePortraitFrame?.setVisible(true);
this.dialoguePortrait
.setTexture(entry.textureKey)
.setDisplaySize(218, 218)
.setVisible(true);
this.dialogueNameText?.setX(350);
this.dialogueBodyText?.setX(350).setWordWrapWidth(1382, true);
} else {
this.dialoguePortraitFrame?.setVisible(false);
this.dialoguePortrait.setVisible(false);
this.dialogueNameText?.setX(128);
this.dialogueBodyText?.setX(128).setWordWrapWidth(1604, true);
}
}
}
@@ -1800,9 +2273,33 @@ export class PrologueVillageScene extends Phaser.Scene {
}
];
return targets
.map((target) => ({ target, distance: this.distanceTo(target.x, target.y) }))
.map((target) => ({
target,
distance: this.distanceTo(target.x, target.y),
priority: this.interactionTargetPriority(target)
}))
.filter(({ distance }) => distance <= radius)
.sort((left, right) => left.distance - right.distance)[0]?.target;
.sort((left, right) => (
left.priority - right.priority ||
left.distance - right.distance
))[0]?.target;
}
private interactionTargetPriority(target: InteractionTarget) {
if (target.kind === 'oath') {
return this.allRequiredObjectivesComplete() ? 0 : 4;
}
const definition = this.npcViews.get(target.id)?.definition;
if (!definition?.objectiveId) {
return 2;
}
if (!this.isObjectiveComplete(definition.objectiveId)) {
return this.objectiveUnlocked(definition.objectiveId) ? 0 : 3;
}
if (definition.id === 'guan-yu' || definition.id === 'zhang-fei') {
return 4;
}
return 2;
}
private interactionTargetById(targetId: string): InteractionTarget | undefined {

View File

@@ -19,6 +19,7 @@ import {
isSortieOrderId,
sortieOrderCheckIdsByOrder,
sortieOrderDefinition,
sortieOrderDisplayLabel,
sortieOrderIds,
sortieOrderRewardClaimId,
type CampaignSortieOrderCommandSnapshot,
@@ -1142,9 +1143,9 @@ export function campaignVictoryRewardPresentation(report: CampaignVictoryRewardR
? sortieOrderDefinition(report.sortieOrder.orderId)
: undefined;
const sortieOrderBonus = definition
? {
? {
orderId: definition.id,
label: definition.label,
label: sortieOrderDisplayLabel(report.battleId, definition.id),
rewardGold: definition.rewardGold,
rewardItems: [...definition.rewardItems]
}