feat: connect prologue choices to first battle
This commit is contained in:
141
docs/game-design-improvement-goal.md
Normal file
141
docs/game-design-improvement-goal.md
Normal 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):
|
||||
명확한 정보와 제한된 규칙으로 깊은 전술 선택 만들기
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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}`]
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user