feat: turn first victory recall into camp preparation
This commit is contained in:
@@ -113,9 +113,24 @@ RPG로 다듬는다.
|
||||
- 세 군령의 달성·미달 여섯 조합, 선택 대화의 저장·재개, 첫 교전 중복
|
||||
방지, 이후 전투 누출 방지와 1920x1080 배치를 자동 검증한다.
|
||||
|
||||
다음 초반 묶음은 첫 승리에서 드러난 군령의 남은 과제가 첫 군영의 동료
|
||||
대화와 다음 편성 제안으로 이어지게 해, 회고가 설명으로 끝나지 않고 다음
|
||||
준비 행동을 바꾸도록 연결하는 것을 우선한다.
|
||||
### 완료한 4차 묶음: 회고를 다음 준비 행동으로
|
||||
|
||||
- 첫 승리에서 선택한 군령, 달성 여부와 첫 미달 조건을 첫 군영에서도 같은
|
||||
저장 기록으로 다시 파생한다.
|
||||
- 첫 군영의 대화 탭과 관련 동료 대화 한 곳에만 `회고` 표식을 보여 주며,
|
||||
정적 대화 ID·선택지·공명 보상은 유지한다.
|
||||
- 관련 대화는 군령별 인물 조합과 실제 결과에 맞는 짧은 세 줄로 바꾸되,
|
||||
오래되거나 불완전한 저장은 기존 대사를 그대로 사용한다.
|
||||
- 첫 추격전 브리핑에서 회고의 한 가지 보완 또는 확장 과제를 보여 주고
|
||||
`추천 군령 보기`로 해당 작전 명령 카드를 강조한다.
|
||||
- 추천은 군령을 자동 선택하지 않는다. 플레이어가 조건과 보상을 다시
|
||||
확인하고 직접 선택한 순간에만 기존 저장 흐름으로 반영한다.
|
||||
- 미달했다면 같은 군령의 첫 미달 조건을 보완하고, 완수했다면 잔당
|
||||
추격전의 성격에 맞는 기동 군령을 다음 학습 과제로 제안한다.
|
||||
|
||||
다음 초반 묶음은 전투에서 실제로 함께 협공·엄호·회복한 사건을 동료가
|
||||
짧게 기억하고, 거점의 공명 대화와 다음 전투의 작은 협동 효과로 연결하는
|
||||
것을 우선 검토한다.
|
||||
|
||||
## 장기 개선 순서
|
||||
|
||||
@@ -169,3 +184,11 @@ RPG로 다듬는다.
|
||||
전략층과 전술층이 서로 의미를 만드는 이중 구조
|
||||
- [Into the Breach 디자인 회고, GDC](https://www.gdcvault.com/play/1025772/-Into-the-Breach-Design):
|
||||
명확한 정보와 제한된 규칙으로 깊은 전술 선택 만들기
|
||||
- [Nintendo: Fire Emblem Three Houses 101](https://www.nintendo.com/au/news-and-articles/fire-emblem-three-houses-101/):
|
||||
자유 탐색·관계 대화와 출전 인원·장비·정찰을 한 전투 준비 순환으로 연결
|
||||
- [Nintendo: Fire Emblem Engage 전투와 유대](https://www.nintendo.com/ph/switch/ayfn/system/battle.html):
|
||||
함께 싸우고 회복한 경험을 거점 대화와 다음 전투의 인접 지원으로 회수
|
||||
- [Darkest Dungeon Affliction System, Game Developer](https://www.gamedeveloper.com/design/game-design-deep-dive-i-darkest-dungeon-s-i-affliction-system):
|
||||
원정의 결과가 귀환 뒤 인물별 준비 선택으로 이어지고 수치 결과는 명확히 공개
|
||||
- [Into the Breach Design Postmortem PDF](https://media.gdcvault.com/gdc2019/presentations/Into%20the%20Breach%20Postmortem%20Final.pdf):
|
||||
모호한 전략층을 명확한 보상, 미리 계획 가능한 선택과 단일 화면으로 압축
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
"verify:city-stays": "node scripts/verify-city-stay-data.mjs",
|
||||
"verify:city-stays:browser": "node scripts/verify-city-stay-browser.mjs",
|
||||
"verify:prologue-village": "node scripts/verify-prologue-village-data.mjs",
|
||||
"verify:first-battle-camp-followup": "node scripts/verify-first-battle-camp-followup.mjs",
|
||||
"verify:prologue-exploration-assets": "node scripts/verify-prologue-exploration-asset-data.mjs",
|
||||
"verify:exploration-characters": "node scripts/verify-exploration-character-asset-data.mjs",
|
||||
"verify:prologue-dialogue-portraits": "node scripts/verify-prologue-dialogue-portrait-data.mjs",
|
||||
|
||||
334
scripts/verify-first-battle-camp-followup.mjs
Normal file
334
scripts/verify-first-battle-camp-followup.mjs
Normal file
@@ -0,0 +1,334 @@
|
||||
import { createServer } from 'vite';
|
||||
|
||||
const server = await createServer({
|
||||
logLevel: 'error',
|
||||
server: { middlewareMode: true },
|
||||
appType: 'custom'
|
||||
});
|
||||
|
||||
try {
|
||||
const followup = await server.ssrLoadModule(
|
||||
'/src/game/data/firstBattleCampFollowup.ts'
|
||||
);
|
||||
const narrative = await server.ssrLoadModule(
|
||||
'/src/game/data/firstBattleNarrativeMemory.ts'
|
||||
);
|
||||
const orders = await server.ssrLoadModule('/src/game/data/sortieOrders.ts');
|
||||
const failures = [];
|
||||
|
||||
const missedCases = [
|
||||
{
|
||||
orderId: 'elite',
|
||||
criterionId: 'elite-grade',
|
||||
criterionLabel: '편성 평가 A 이상',
|
||||
mentorUnitId: 'liu-bei',
|
||||
mentorName: '유비',
|
||||
targetDialogueId: 'liu-guan-after-first-battle'
|
||||
},
|
||||
{
|
||||
orderId: 'elite',
|
||||
criterionId: 'elite-survival',
|
||||
criterionLabel: '출전 전원 생존',
|
||||
mentorUnitId: 'guan-yu',
|
||||
mentorName: '관우',
|
||||
targetDialogueId: 'liu-guan-after-first-battle'
|
||||
},
|
||||
{
|
||||
orderId: 'mobile',
|
||||
criterionId: 'mobile-quick',
|
||||
criterionLabel: '제한 턴 이내 승리',
|
||||
mentorUnitId: 'zhang-fei',
|
||||
mentorName: '장비',
|
||||
targetDialogueId: 'liu-zhang-after-first-battle'
|
||||
},
|
||||
{
|
||||
orderId: 'mobile',
|
||||
criterionId: 'mobile-breakthrough',
|
||||
criterionLabel: '돌파 기여 1회',
|
||||
mentorUnitId: 'zhang-fei',
|
||||
mentorName: '장비',
|
||||
targetDialogueId: 'liu-zhang-after-first-battle'
|
||||
},
|
||||
{
|
||||
orderId: 'siege',
|
||||
criterionId: 'siege-objective',
|
||||
criterionLabel: '거점·보너스 목표 달성',
|
||||
mentorUnitId: 'guan-yu',
|
||||
mentorName: '관우',
|
||||
targetDialogueId: 'guan-zhang-after-first-battle'
|
||||
},
|
||||
{
|
||||
orderId: 'siege',
|
||||
criterionId: 'siege-front',
|
||||
criterionLabel: '전열 기여 1 이상',
|
||||
mentorUnitId: 'guan-yu',
|
||||
mentorName: '관우',
|
||||
targetDialogueId: 'guan-zhang-after-first-battle'
|
||||
},
|
||||
{
|
||||
orderId: 'siege',
|
||||
criterionId: 'siege-support',
|
||||
criterionLabel: '후원 기여 1 이상',
|
||||
mentorUnitId: 'guan-yu',
|
||||
mentorName: '관우',
|
||||
targetDialogueId: 'guan-zhang-after-first-battle'
|
||||
}
|
||||
];
|
||||
|
||||
for (const testCase of missedCases) {
|
||||
const report = makeReport(
|
||||
narrative.firstBattleNarrativeBattleId,
|
||||
orders.sortieOrderCheckIdsByOrder,
|
||||
testCase.orderId,
|
||||
false,
|
||||
[testCase.criterionId]
|
||||
);
|
||||
const snapshot = JSON.stringify(report);
|
||||
deepFreeze(report);
|
||||
const result = followup.resolveFirstBattleCampFollowup(report);
|
||||
const label = `${testCase.orderId}/${testCase.criterionId}`;
|
||||
|
||||
assert(result.source === 'campaign', `${label}: campaign result required`, failures);
|
||||
if (result.source !== 'campaign') {
|
||||
continue;
|
||||
}
|
||||
assert(result.fallbackReason === null, `${label}: fallback reason must be null`, failures);
|
||||
assert(result.originalOrderId === testCase.orderId, `${label}: original order`, failures);
|
||||
assert(result.achieved === false, `${label}: missed status`, failures);
|
||||
assert(result.focusCriterionId === testCase.criterionId, `${label}: focus criterion`, failures);
|
||||
assert(result.focusCriterionLabel === testCase.criterionLabel, `${label}: focus label`, failures);
|
||||
assert(result.mentorUnitId === testCase.mentorUnitId, `${label}: mentor unit`, failures);
|
||||
assert(result.mentorName === testCase.mentorName, `${label}: mentor name`, failures);
|
||||
assert(
|
||||
result.targetDialogueId === testCase.targetDialogueId,
|
||||
`${label}: dialogue mapping`,
|
||||
failures
|
||||
);
|
||||
assert(
|
||||
result.recommendedNextOrderId === testCase.orderId,
|
||||
`${label}: missed order must be retried`,
|
||||
failures
|
||||
);
|
||||
assert(
|
||||
result.recommendationReason.includes(testCase.criterionLabel),
|
||||
`${label}: recommendation must name missed criterion`,
|
||||
failures
|
||||
);
|
||||
verifyPresentation(result, label, failures);
|
||||
assert(JSON.stringify(report) === snapshot, `${label}: source report must remain immutable`, failures);
|
||||
}
|
||||
|
||||
const expectedDialogueByOrder = {
|
||||
elite: 'liu-guan-after-first-battle',
|
||||
mobile: 'liu-zhang-after-first-battle',
|
||||
siege: 'guan-zhang-after-first-battle'
|
||||
};
|
||||
for (const orderId of orders.sortieOrderIds) {
|
||||
const report = makeReport(
|
||||
narrative.firstBattleNarrativeBattleId,
|
||||
orders.sortieOrderCheckIdsByOrder,
|
||||
orderId,
|
||||
true,
|
||||
[]
|
||||
);
|
||||
const result = followup.resolveFirstBattleCampFollowup(report);
|
||||
const label = `${orderId}/achieved`;
|
||||
|
||||
assert(result.source === 'campaign', `${label}: campaign result required`, failures);
|
||||
if (result.source !== 'campaign') {
|
||||
continue;
|
||||
}
|
||||
assert(result.achieved === true, `${label}: achieved status`, failures);
|
||||
assert(
|
||||
result.recommendedNextOrderId === 'mobile',
|
||||
`${label}: pursuit context must recommend mobile`,
|
||||
failures
|
||||
);
|
||||
assert(
|
||||
result.focusCriterionId === 'mobile-quick',
|
||||
`${label}: pursuit focus must be turn limit`,
|
||||
failures
|
||||
);
|
||||
assert(
|
||||
result.targetDialogueId === expectedDialogueByOrder[orderId],
|
||||
`${label}: original order dialogue mapping`,
|
||||
failures
|
||||
);
|
||||
assert(
|
||||
result.recommendationReason.includes('잔당 추격전'),
|
||||
`${label}: recommendation must explain the next battle context`,
|
||||
failures
|
||||
);
|
||||
verifyPresentation(result, label, failures);
|
||||
}
|
||||
|
||||
const firstMissedReport = makeReport(
|
||||
narrative.firstBattleNarrativeBattleId,
|
||||
orders.sortieOrderCheckIdsByOrder,
|
||||
'siege',
|
||||
false,
|
||||
['siege-objective', 'siege-support']
|
||||
);
|
||||
const firstMissed = followup.resolveFirstBattleCampFollowup(firstMissedReport);
|
||||
assert(
|
||||
firstMissed.source === 'campaign' &&
|
||||
firstMissed.focusCriterionId === 'siege-objective',
|
||||
'multiple misses must focus the first authored criterion',
|
||||
failures
|
||||
);
|
||||
|
||||
const malformedCases = [
|
||||
{
|
||||
label: 'missing report',
|
||||
report: undefined,
|
||||
reason: 'missing-report'
|
||||
},
|
||||
{
|
||||
label: 'old save without sortie order',
|
||||
report: {
|
||||
battleId: narrative.firstBattleNarrativeBattleId,
|
||||
outcome: 'victory'
|
||||
},
|
||||
reason: 'missing-order'
|
||||
},
|
||||
{
|
||||
label: 'different battle',
|
||||
report: {
|
||||
battleId: 'second-battle-yellow-turban-pursuit',
|
||||
outcome: 'victory'
|
||||
},
|
||||
reason: 'battle-mismatch'
|
||||
},
|
||||
{
|
||||
label: 'defeat',
|
||||
report: {
|
||||
battleId: narrative.firstBattleNarrativeBattleId,
|
||||
outcome: 'defeat'
|
||||
},
|
||||
reason: 'non-victory'
|
||||
},
|
||||
{
|
||||
label: 'missing progress criterion',
|
||||
report: {
|
||||
battleId: narrative.firstBattleNarrativeBattleId,
|
||||
outcome: 'victory',
|
||||
sortieOrder: {
|
||||
orderId: 'elite',
|
||||
achieved: false,
|
||||
progress: [{ id: 'victory', achieved: true }]
|
||||
}
|
||||
},
|
||||
reason: 'invalid-order-progress'
|
||||
},
|
||||
{
|
||||
label: 'inconsistent achieved flag',
|
||||
report: makeReport(
|
||||
narrative.firstBattleNarrativeBattleId,
|
||||
orders.sortieOrderCheckIdsByOrder,
|
||||
'mobile',
|
||||
true,
|
||||
['mobile-quick']
|
||||
),
|
||||
reason: 'invalid-order-progress'
|
||||
},
|
||||
{
|
||||
label: 'inconsistent missed flag',
|
||||
report: makeReport(
|
||||
narrative.firstBattleNarrativeBattleId,
|
||||
orders.sortieOrderCheckIdsByOrder,
|
||||
'siege',
|
||||
false,
|
||||
[]
|
||||
),
|
||||
reason: 'invalid-order-progress'
|
||||
}
|
||||
];
|
||||
|
||||
for (const testCase of malformedCases) {
|
||||
const snapshot = JSON.stringify(testCase.report);
|
||||
if (testCase.report) {
|
||||
deepFreeze(testCase.report);
|
||||
}
|
||||
const result = followup.resolveFirstBattleCampFollowup(testCase.report);
|
||||
assert(result.source === 'fallback', `${testCase.label}: fallback required`, failures);
|
||||
assert(
|
||||
result.source === 'fallback' && result.fallbackReason === testCase.reason,
|
||||
`${testCase.label}: expected ${testCase.reason}`,
|
||||
failures
|
||||
);
|
||||
assert(
|
||||
JSON.stringify(testCase.report) === snapshot,
|
||||
`${testCase.label}: fallback must not mutate report`,
|
||||
failures
|
||||
);
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
throw new Error(
|
||||
`First-battle camp follow-up verification failed:\n- ${failures.join('\n- ')}`
|
||||
);
|
||||
}
|
||||
console.log(
|
||||
`First-battle camp follow-up verification passed (${missedCases.length} missed mappings, ${orders.sortieOrderIds.length} achieved pursuit mappings).`
|
||||
);
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
|
||||
function makeReport(
|
||||
battleId,
|
||||
checkIdsByOrder,
|
||||
orderId,
|
||||
achieved,
|
||||
missedCriterionIds
|
||||
) {
|
||||
return {
|
||||
battleId,
|
||||
outcome: 'victory',
|
||||
turnNumber: 6,
|
||||
mvp: { unitId: 'guan-yu', name: '관우' },
|
||||
sortieOrder: {
|
||||
orderId,
|
||||
achieved,
|
||||
progress: checkIdsByOrder[orderId].map((id) => ({
|
||||
id,
|
||||
achieved: id === 'victory' || !missedCriterionIds.includes(id),
|
||||
value: id === 'victory' || !missedCriterionIds.includes(id) ? 1 : 0,
|
||||
target: 1
|
||||
}))
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function verifyPresentation(result, label, failures) {
|
||||
assert(result.dialogueTitle.trim().length >= 4, `${label}: dialogue title`, failures);
|
||||
assert(result.dialogueLines.length === 3, `${label}: exactly three dialogue lines`, failures);
|
||||
assert(
|
||||
result.dialogueLines.every((line) => line.includes(':') && line.length <= 72),
|
||||
`${label}: concise speaker-labelled dialogue`,
|
||||
failures
|
||||
);
|
||||
assert(
|
||||
result.dialogueLines[0].startsWith(`${result.mentorName}:`),
|
||||
`${label}: mentor must open the dialogue`,
|
||||
failures
|
||||
);
|
||||
assert(result.recommendationTitle.trim().length >= 4, `${label}: recommendation title`, failures);
|
||||
assert(result.recommendationReason.trim().length >= 12, `${label}: recommendation reason`, failures);
|
||||
assert(result.statusLabel.trim().length >= 4, `${label}: status label`, failures);
|
||||
}
|
||||
|
||||
function assert(condition, message, failures) {
|
||||
if (!condition) {
|
||||
failures.push(message);
|
||||
}
|
||||
}
|
||||
|
||||
function deepFreeze(value) {
|
||||
if (!value || typeof value !== 'object' || Object.isFrozen(value)) {
|
||||
return value;
|
||||
}
|
||||
Object.freeze(value);
|
||||
Object.values(value).forEach(deepFreeze);
|
||||
return value;
|
||||
}
|
||||
@@ -2667,6 +2667,138 @@ try {
|
||||
`Expected camp tab hover styling to reset on pointerout: ${JSON.stringify(firstCampTabResetState?.campTabs)}`
|
||||
);
|
||||
|
||||
await page.mouse.click(firstCampDialogueTabPoint.x, firstCampDialogueTabPoint.y);
|
||||
await page.waitForFunction(
|
||||
() => window.__HEROS_DEBUG__?.camp()?.activeTab === 'dialogue',
|
||||
undefined,
|
||||
{ timeout: 30000 }
|
||||
);
|
||||
const firstVictoryDialogueProbe = await page.evaluate(() => {
|
||||
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
|
||||
const state = window.__HEROS_DEBUG__?.camp();
|
||||
const followup = state?.firstVictoryFollowup;
|
||||
const renderedDialogueText = followup?.dialogueLines?.join('\n') ?? null;
|
||||
const activeTexts = (scene?.contentObjects ?? []).filter(
|
||||
(object) => object?.type === 'Text' && object.active && object.visible
|
||||
);
|
||||
const boundsFor = (object) => {
|
||||
const bounds = object?.getBounds?.();
|
||||
return bounds
|
||||
? { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height }
|
||||
: null;
|
||||
};
|
||||
return {
|
||||
state,
|
||||
dialogueTabBadgeText:
|
||||
scene?.tabButtons?.find((button) => button.tab === 'dialogue')?.newBadge?.text ?? null,
|
||||
rowMarkers: activeTexts
|
||||
.filter((object) => object.text === '회고')
|
||||
.map((object) => ({ text: object.text, bounds: boundsFor(object) })),
|
||||
renderedDialogueBlocks: renderedDialogueText
|
||||
? activeTexts
|
||||
.filter((object) => object.text === renderedDialogueText)
|
||||
.map((object) => ({ text: object.text, bounds: boundsFor(object) }))
|
||||
: []
|
||||
};
|
||||
});
|
||||
const firstVictoryFollowup = firstVictoryDialogueProbe.state?.firstVictoryFollowup;
|
||||
const firstVictorySelectedDialogue = firstVictoryDialogueProbe.state?.selectedDialogue;
|
||||
assert(
|
||||
firstVictoryDialogueProbe.state?.activeTab === 'dialogue' &&
|
||||
firstVictoryDialogueProbe.state?.campTabs?.find((tab) => tab.id === 'dialogue')?.newBadgeVisible === true &&
|
||||
firstVictoryDialogueProbe.dialogueTabBadgeText === '회고' &&
|
||||
firstVictoryFollowup?.available === true &&
|
||||
firstVictoryFollowup.sourceBattleId === persistedFirstReport?.battleId &&
|
||||
firstVictoryFollowup.originalOrderId === persistedFirstReport?.sortieOrder?.orderId &&
|
||||
firstVictoryFollowup.originalOrderId === 'elite' &&
|
||||
firstVictoryFollowup.achieved === persistedFirstReport?.sortieOrder?.achieved &&
|
||||
firstVictoryFollowup.achieved === true &&
|
||||
firstVictoryFollowup.targetDialogueId === 'liu-guan-after-first-battle' &&
|
||||
firstVictoryFollowup.recommendedOrderId === 'mobile' &&
|
||||
firstVictoryFollowup.dialogueLines?.length === 3 &&
|
||||
firstVictoryFollowup.dialogueLines.some((line) => line.includes('정예 군령')) &&
|
||||
firstVictoryFollowup.dialogueLines.some((line) => line.includes('빠르게 추격')) &&
|
||||
firstVictorySelectedDialogue?.id === firstVictoryFollowup.targetDialogueId &&
|
||||
firstVictorySelectedDialogue.firstVictoryFollowup === true &&
|
||||
sameJsonValue(firstVictorySelectedDialogue.unitIds, ['liu-bei', 'guan-yu']) &&
|
||||
sameJsonValue(firstVictorySelectedDialogue.lines, firstVictoryFollowup.dialogueLines) &&
|
||||
firstVictoryDialogueProbe.renderedDialogueBlocks.length === 1 &&
|
||||
firstVictoryDialogueProbe.rowMarkers.length === 1 &&
|
||||
boundsInside(
|
||||
firstVictoryDialogueProbe.rowMarkers[0]?.bounds,
|
||||
firstVictoryFollowup.dialogueRowBounds
|
||||
) &&
|
||||
firstVictorySelectedDialogue.choices?.length === 2 &&
|
||||
isFiniteBounds(firstVictorySelectedDialogue.bodyBounds) &&
|
||||
firstVictorySelectedDialogue.bodyBounds.y + firstVictorySelectedDialogue.bodyBounds.height <=
|
||||
firstVictorySelectedDialogue.choices[0]?.bounds?.y - 8 &&
|
||||
firstVictorySelectedDialogue.choices.every((choice) => boundsWithinFhdViewport(choice.bounds)),
|
||||
`Expected the first-camp dialogue tab to render one marked Liu Bei/Guan Yu follow-up from the persisted elite victory: ${JSON.stringify(firstVictoryDialogueProbe)}`
|
||||
);
|
||||
await page.screenshot({
|
||||
path: `${screenshotDir}/rc-first-camp-victory-followup-dialogue.png`,
|
||||
fullPage: true
|
||||
});
|
||||
await assertCanvasPainted(page, 'first camp victory follow-up dialogue');
|
||||
const firstVictoryChoice = firstVictorySelectedDialogue.choices[0];
|
||||
const firstVictoryChoicePoint = await readFirstVictoryFollowupControlPoint(
|
||||
page,
|
||||
'choice',
|
||||
firstVictoryChoice.id
|
||||
);
|
||||
await page.mouse.click(firstVictoryChoicePoint.x, firstVictoryChoicePoint.y);
|
||||
await page.waitForFunction(
|
||||
({ dialogueId, choiceId }) => {
|
||||
const camp = window.__HEROS_DEBUG__?.camp();
|
||||
return (
|
||||
camp?.firstVictoryFollowup?.dialogueCompleted === true &&
|
||||
camp?.selectedDialogue?.completed === true &&
|
||||
camp?.campaign?.campDialogueChoiceIds?.[dialogueId] === choiceId
|
||||
);
|
||||
},
|
||||
{
|
||||
dialogueId: firstVictoryFollowup.targetDialogueId,
|
||||
choiceId: firstVictoryChoice.id
|
||||
},
|
||||
{ timeout: 30000 }
|
||||
);
|
||||
const completedFirstVictoryDialogueProbe = await page.evaluate(() => {
|
||||
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
|
||||
return {
|
||||
state: window.__HEROS_DEBUG__?.camp(),
|
||||
remainingRowMarkerCount: (scene?.contentObjects ?? []).filter(
|
||||
(object) => object?.type === 'Text' && object.active && object.visible && object.text === '회고'
|
||||
).length
|
||||
};
|
||||
});
|
||||
const completedFirstVictoryDialogueSave = await readCampaignSave(page);
|
||||
const firstVictoryDialoguePersisted = (save) => (
|
||||
save?.completedCampDialogues?.includes(firstVictoryFollowup.targetDialogueId) &&
|
||||
save?.firstBattleReport?.completedCampDialogues?.includes(firstVictoryFollowup.targetDialogueId) &&
|
||||
save?.campDialogueChoiceIds?.[firstVictoryFollowup.targetDialogueId] === firstVictoryChoice.id
|
||||
);
|
||||
assert(
|
||||
completedFirstVictoryDialogueProbe.state?.firstVictoryFollowup?.dialogueCompleted === true &&
|
||||
completedFirstVictoryDialogueProbe.state?.selectedDialogue?.completed === true &&
|
||||
completedFirstVictoryDialogueProbe.state?.completedAvailableDialogues?.includes(
|
||||
firstVictoryFollowup.targetDialogueId
|
||||
) &&
|
||||
completedFirstVictoryDialogueProbe.state?.campaign?.campDialogueChoiceIds?.[
|
||||
firstVictoryFollowup.targetDialogueId
|
||||
] === firstVictoryChoice.id &&
|
||||
completedFirstVictoryDialogueProbe.state?.campTabs?.find(
|
||||
(tab) => tab.id === 'dialogue'
|
||||
)?.newBadgeVisible === false &&
|
||||
completedFirstVictoryDialogueProbe.remainingRowMarkerCount === 0 &&
|
||||
firstVictoryDialoguePersisted(completedFirstVictoryDialogueSave.current) &&
|
||||
firstVictoryDialoguePersisted(completedFirstVictoryDialogueSave.slot1),
|
||||
`Expected the actual first-victory choice to persist in current and slot saves and clear both follow-up markers: ${JSON.stringify({
|
||||
probe: completedFirstVictoryDialogueProbe,
|
||||
save: completedFirstVictoryDialogueSave,
|
||||
choiceId: firstVictoryChoice.id
|
||||
})}`
|
||||
);
|
||||
|
||||
const firstCampEvaluation = firstCampProbe.state?.reportFormationEvaluation;
|
||||
const firstCampHistory = firstCampProbe.state?.reportFormationHistory;
|
||||
const firstCampEvaluationSaveBefore = await readCampaignSave(page);
|
||||
@@ -3459,12 +3591,86 @@ try {
|
||||
firstSortieBriefingState?.sortiePrimaryAction?.lineWidth >= 2 &&
|
||||
firstSortieBriefingState?.sortieNextActionGuide?.label?.includes('무장 편성으로 이동') &&
|
||||
boundsWithinFhdViewport(firstSortieBriefingState?.sortieNextActionGuide?.bounds) &&
|
||||
boundsWithinFhdViewport(firstSortieBriefingState?.sortiePrimaryAction?.bounds),
|
||||
boundsWithinFhdViewport(firstSortieBriefingState?.sortiePrimaryAction?.bounds) &&
|
||||
firstSortieBriefingState?.firstVictoryFollowup?.originalOrderId === 'elite' &&
|
||||
firstSortieBriefingState.firstVictoryFollowup.achieved === true &&
|
||||
firstSortieBriefingState.firstVictoryFollowup.recommendedOrderId === 'mobile' &&
|
||||
firstSortieBriefingState.firstVictoryFollowup.ctaInteractive === true &&
|
||||
boundsWithinFhdViewport(firstSortieBriefingState.firstVictoryFollowup.ctaBounds) &&
|
||||
boundsInside(
|
||||
firstSortieBriefingState.firstVictoryFollowup.handoffSummaryBounds,
|
||||
firstSortieBriefingState.firstVictoryFollowup.handoffCardBounds
|
||||
) &&
|
||||
boundsInside(
|
||||
firstSortieBriefingState.firstVictoryFollowup.ctaBounds,
|
||||
firstSortieBriefingState.firstVictoryFollowup.handoffCardBounds
|
||||
),
|
||||
`Expected the first sortie to open at the staged battlefield briefing: ${JSON.stringify(firstSortieBriefingState)}`
|
||||
);
|
||||
await advanceSortiePrepStep(page, 'formation');
|
||||
const firstFollowupOrderCtaPoint = await readFirstVictoryFollowupControlPoint(page, 'cta');
|
||||
await page.mouse.click(firstFollowupOrderCtaPoint.x, firstFollowupOrderCtaPoint.y);
|
||||
await page.waitForFunction(() => {
|
||||
const camp = window.__HEROS_DEBUG__?.camp?.();
|
||||
return (
|
||||
camp?.sortiePrepStep === 'formation' &&
|
||||
camp?.sortieOperationOrder?.open === true
|
||||
);
|
||||
}, undefined, { timeout: 30000 });
|
||||
const firstFollowupOrderState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
|
||||
const firstFollowupRecommendedCards = firstFollowupOrderState?.sortieOperationOrder?.cards?.filter(
|
||||
(card) => card.recommended
|
||||
) ?? [];
|
||||
const firstFollowupMobileCard = firstFollowupOrderState?.sortieOperationOrder?.cards?.find(
|
||||
(card) => card.id === 'mobile'
|
||||
);
|
||||
assert(
|
||||
firstFollowupOrderState?.sortiePrepStep === 'formation' &&
|
||||
firstFollowupOrderState?.sortieOperationOrder?.open === true &&
|
||||
firstFollowupOrderState.sortieOperationOrder.formationPanelMode === 'order' &&
|
||||
firstFollowupOrderState.sortieOperationOrder.recommendedId === 'mobile' &&
|
||||
firstFollowupOrderState.sortieOperationOrder.selectedId === null &&
|
||||
firstFollowupOrderState.sortieOperationOrder.complete === false &&
|
||||
firstFollowupRecommendedCards.length === 1 &&
|
||||
firstFollowupRecommendedCards[0]?.id === 'mobile' &&
|
||||
firstFollowupMobileCard?.recommended === true &&
|
||||
firstFollowupMobileCard.selected === false &&
|
||||
boundsWithinFhdViewport(firstFollowupMobileCard.cardBounds) &&
|
||||
firstFollowupOrderState?.firstVictoryFollowup?.recommendedOrderId === 'mobile' &&
|
||||
firstFollowupOrderState.firstVictoryFollowup.recommendedOrderSelected === false &&
|
||||
sameJsonValue(
|
||||
firstFollowupOrderState.firstVictoryFollowup.recommendedOrderCardBounds,
|
||||
firstFollowupMobileCard.cardBounds
|
||||
),
|
||||
`Expected the briefing follow-up to open three unselected order choices with only mobile highlighted: ${JSON.stringify(firstFollowupOrderState?.sortieOperationOrder)}`
|
||||
);
|
||||
await page.screenshot({ path: `${screenshotDir}/rc-first-camp-sortie-formation.png`, fullPage: true });
|
||||
await assertCanvasPainted(page, 'first camp sortie formation');
|
||||
const firstFollowupMobileCardPoint = await readSortieOrderControlPoint(page, 'card', 'mobile');
|
||||
await page.mouse.click(firstFollowupMobileCardPoint.x, firstFollowupMobileCardPoint.y);
|
||||
await page.waitForFunction(() => {
|
||||
const camp = window.__HEROS_DEBUG__?.camp?.();
|
||||
return (
|
||||
camp?.sortieOperationOrder?.selectedId === 'mobile' &&
|
||||
camp?.sortieOperationOrder?.complete === true
|
||||
);
|
||||
}, undefined, { timeout: 30000 });
|
||||
const selectedFirstFollowupOrderSave = await readCampaignSave(page);
|
||||
const firstFollowupOrderPersisted = (save) => (
|
||||
save?.sortieOrderSelection?.battleId === 'second-battle-yellow-turban-pursuit' &&
|
||||
save?.sortieOrderSelection?.orderId === 'mobile'
|
||||
);
|
||||
assert(
|
||||
firstFollowupOrderPersisted(selectedFirstFollowupOrderSave.current) &&
|
||||
firstFollowupOrderPersisted(selectedFirstFollowupOrderSave.slot1),
|
||||
`Expected the highlighted mobile order to persist for the second battle in current and slot saves: ${JSON.stringify(selectedFirstFollowupOrderSave)}`
|
||||
);
|
||||
const firstFollowupOrderClosePoint = await readSortieOrderControlPoint(page, 'close');
|
||||
await page.mouse.click(firstFollowupOrderClosePoint.x, firstFollowupOrderClosePoint.y);
|
||||
await page.waitForFunction(
|
||||
() => window.__HEROS_DEBUG__?.camp?.()?.sortieOperationOrder?.open === false,
|
||||
undefined,
|
||||
{ timeout: 30000 }
|
||||
);
|
||||
const firstSortieFormationState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
|
||||
const firstSortieSynergy = firstSortieFormationState?.sortieSynergyPreview;
|
||||
assert(
|
||||
@@ -3476,7 +3682,11 @@ try {
|
||||
firstSortieSynergy?.coveredRoleCount === 3 &&
|
||||
firstSortieSynergy?.strongestBond?.damageBonus === 9 &&
|
||||
firstSortieSynergy?.strongestBond?.chainRate === 18 &&
|
||||
firstSortieSynergy?.firstPursuitTrinityConfigured === true,
|
||||
firstSortieSynergy?.firstPursuitTrinityConfigured === true &&
|
||||
firstSortieFormationState?.sortieOperationOrder?.open === false &&
|
||||
firstSortieFormationState.sortieOperationOrder.selectedId === 'mobile' &&
|
||||
firstSortieFormationState.sortieOperationOrder.complete === true &&
|
||||
firstSortieFormationState?.firstVictoryFollowup?.recommendedOrderSelected === true,
|
||||
`Expected first sortie formation to expose its next action and preview three bonds, the strongest effect, and trinity: ${JSON.stringify(firstSortieFormationState)}`
|
||||
);
|
||||
|
||||
@@ -3915,12 +4125,12 @@ try {
|
||||
const secondBattleProbe = await readBattleDoctrineProbe(page);
|
||||
assert(secondBattleProbe?.battleId === 'second-battle-yellow-turban-pursuit', `Expected first camp sortie to enter second battle: ${JSON.stringify(secondBattleProbe)}`);
|
||||
assert(
|
||||
secondBattleProbe?.sortieOperationOrder?.selectedId === 'elite' && secondBattleProbe.sortieOperationOrder.result === null,
|
||||
`Expected the explicitly declared elite order to reach the next battle unchanged: ${JSON.stringify(secondBattleProbe?.sortieOperationOrder)}`
|
||||
secondBattleProbe?.sortieOperationOrder?.selectedId === 'mobile' && secondBattleProbe.sortieOperationOrder.result === null,
|
||||
`Expected the explicitly accepted mobile follow-up order to reach the next battle unchanged: ${JSON.stringify(secondBattleProbe?.sortieOperationOrder)}`
|
||||
);
|
||||
await assertLiveSortieOrderHud(page, {
|
||||
battleId: 'second-battle-yellow-turban-pursuit',
|
||||
orderId: 'elite',
|
||||
orderId: 'mobile',
|
||||
context: 'second battle'
|
||||
});
|
||||
assertSameMembers(
|
||||
@@ -8574,7 +8784,21 @@ async function assertLiveSortieOrderHud(page, { battleId, orderId, context }) {
|
||||
}, { expectedBattleId: battleId, expectedOrderId: orderId });
|
||||
|
||||
const hud = probe.hud;
|
||||
const requiredCriteriaIds = ['elite-grade', 'elite-survival'];
|
||||
const orderExpectation = {
|
||||
elite: {
|
||||
label: '정예',
|
||||
criteriaIds: ['elite-grade', 'elite-survival']
|
||||
},
|
||||
mobile: {
|
||||
label: '기동',
|
||||
criteriaIds: ['mobile-quick', 'mobile-breakthrough']
|
||||
},
|
||||
siege: {
|
||||
label: '공성',
|
||||
criteriaIds: ['siege-objective', 'siege-front', 'siege-support']
|
||||
}
|
||||
}[orderId];
|
||||
const requiredCriteriaIds = orderExpectation?.criteriaIds ?? [];
|
||||
const criterionIds = hud?.criteria?.map((entry) => entry.id) ?? [];
|
||||
assert(
|
||||
probe.battleId === battleId &&
|
||||
@@ -8588,7 +8812,7 @@ async function assertLiveSortieOrderHud(page, { battleId, orderId, context }) {
|
||||
assert(
|
||||
hud?.visible === true &&
|
||||
hud.orderId === orderId &&
|
||||
typeof hud.label === 'string' && hud.label.includes('정예') &&
|
||||
typeof hud.label === 'string' && hud.label.includes(orderExpectation?.label ?? '') &&
|
||||
typeof hud.victoryLabel === 'string' && hud.victoryLabel.length > 0 &&
|
||||
typeof hud.victoryTone === 'string' && hud.victoryTone.length > 0 &&
|
||||
typeof hud.status === 'string' && hud.status.length > 0 &&
|
||||
@@ -9824,13 +10048,18 @@ async function readCampStaticTextControlPoint(page, label) {
|
||||
async function readCampTabControlPoint(page, tab) {
|
||||
const probe = await page.evaluate((targetTab) => {
|
||||
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
|
||||
const state = window.__HEROS_DEBUG__?.camp?.();
|
||||
const canvas = document.querySelector('canvas');
|
||||
const button = scene?.tabButtons?.find((candidate) => candidate.tab === targetTab);
|
||||
const target = button?.bg;
|
||||
if (!scene || !canvas || !target?.active || !target.visible || !target.input?.enabled) {
|
||||
return { ready: false, tab: targetTab, availableTabs: scene?.tabButtons?.map((candidate) => candidate.tab) ?? [] };
|
||||
const tab = state?.campTabs?.find((candidate) => candidate.id === targetTab);
|
||||
const bounds = tab?.bounds;
|
||||
if (!scene || !canvas || !tab?.interactive || !bounds) {
|
||||
return {
|
||||
ready: false,
|
||||
tab: targetTab,
|
||||
availableTabs: state?.campTabs?.map((candidate) => candidate.id) ?? [],
|
||||
bounds: bounds ?? null
|
||||
};
|
||||
}
|
||||
const bounds = target.getBounds();
|
||||
const canvasBounds = canvas.getBoundingClientRect();
|
||||
const centerX = bounds.x + bounds.width / 2;
|
||||
const centerY = bounds.y + bounds.height / 2;
|
||||
@@ -9849,6 +10078,49 @@ async function readCampTabControlPoint(page, tab) {
|
||||
return probe;
|
||||
}
|
||||
|
||||
async function readFirstVictoryFollowupControlPoint(page, control, choiceId) {
|
||||
const probe = await page.evaluate(({ control, choiceId }) => {
|
||||
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
|
||||
const state = window.__HEROS_DEBUG__?.camp?.();
|
||||
const canvas = document.querySelector('canvas');
|
||||
const followup = state?.firstVictoryFollowup;
|
||||
const choice = state?.selectedDialogue?.choices?.find((candidate) => candidate.id === choiceId);
|
||||
const bounds = control === 'cta' ? followup?.ctaBounds : choice?.bounds;
|
||||
const interactive = control === 'cta'
|
||||
? followup?.ctaInteractive === true
|
||||
: Boolean(choice?.bounds);
|
||||
if (!scene || !canvas || !interactive || !bounds) {
|
||||
return {
|
||||
ready: false,
|
||||
control,
|
||||
choiceId: choiceId ?? null,
|
||||
bounds: bounds ?? null,
|
||||
followup: followup ?? null,
|
||||
availableChoiceIds: state?.selectedDialogue?.choices?.map((candidate) => candidate.id) ?? []
|
||||
};
|
||||
}
|
||||
const canvasBounds = canvas.getBoundingClientRect();
|
||||
const centerX = bounds.x + bounds.width / 2;
|
||||
const centerY = bounds.y + bounds.height / 2;
|
||||
return {
|
||||
ready: true,
|
||||
control,
|
||||
choiceId: choiceId ?? null,
|
||||
bounds,
|
||||
x: canvasBounds.left + centerX * canvasBounds.width / scene.scale.width,
|
||||
y: canvasBounds.top + centerY * canvasBounds.height / scene.scale.height
|
||||
};
|
||||
}, { control, choiceId });
|
||||
assert(
|
||||
probe.ready === true &&
|
||||
Number.isFinite(probe.x) &&
|
||||
Number.isFinite(probe.y) &&
|
||||
boundsWithinFhdViewport(probe.bounds),
|
||||
`Expected an interactive first-victory ${control} control from debug bounds: ${JSON.stringify(probe)}`
|
||||
);
|
||||
return probe;
|
||||
}
|
||||
|
||||
async function readSortieTextControlPoint(page, label) {
|
||||
const probe = await page.evaluate((targetLabel) => {
|
||||
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
|
||||
|
||||
@@ -23,6 +23,7 @@ const checks = [
|
||||
'scripts/verify-camp-reward-data.mjs',
|
||||
'scripts/verify-city-stay-data.mjs',
|
||||
'scripts/verify-prologue-village-data.mjs',
|
||||
'scripts/verify-first-battle-camp-followup.mjs',
|
||||
'scripts/verify-prologue-exploration-asset-data.mjs',
|
||||
'scripts/verify-exploration-character-asset-data.mjs',
|
||||
'scripts/verify-prologue-dialogue-portrait-data.mjs',
|
||||
|
||||
257
src/game/data/firstBattleCampFollowup.ts
Normal file
257
src/game/data/firstBattleCampFollowup.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
import {
|
||||
resolveFirstBattleVictoryNarrative,
|
||||
type FirstBattleNarrativeReport,
|
||||
type FirstBattleVictoryNarrativeFallbackReason
|
||||
} from './firstBattleNarrativeMemory';
|
||||
import {
|
||||
sortieOrderDefinition,
|
||||
type CampaignSortieOrderId,
|
||||
type CampaignSortieOrderProgressId
|
||||
} from './sortieOrders';
|
||||
|
||||
export type FirstBattleCampMentorUnitId = 'liu-bei' | 'guan-yu' | 'zhang-fei';
|
||||
export type FirstBattleCampFocusCriterionId = Exclude<
|
||||
CampaignSortieOrderProgressId,
|
||||
'victory'
|
||||
>;
|
||||
|
||||
export type FirstBattleCampFollowup = {
|
||||
source: 'campaign';
|
||||
fallbackReason: null;
|
||||
originalOrderId: CampaignSortieOrderId;
|
||||
originalOrderLabel: string;
|
||||
achieved: boolean;
|
||||
focusCriterionId: FirstBattleCampFocusCriterionId;
|
||||
focusCriterionLabel: string;
|
||||
mentorUnitId: FirstBattleCampMentorUnitId;
|
||||
mentorName: string;
|
||||
targetDialogueId:
|
||||
| 'liu-guan-after-first-battle'
|
||||
| 'liu-zhang-after-first-battle'
|
||||
| 'guan-zhang-after-first-battle';
|
||||
dialogueTitle: string;
|
||||
dialogueLines: [string, string, string];
|
||||
recommendedNextOrderId: CampaignSortieOrderId;
|
||||
recommendedNextOrderLabel: string;
|
||||
recommendationTitle: string;
|
||||
recommendationReason: string;
|
||||
statusLabel: string;
|
||||
};
|
||||
|
||||
export type FirstBattleCampFollowupFallback = {
|
||||
source: 'fallback';
|
||||
fallbackReason: FirstBattleVictoryNarrativeFallbackReason;
|
||||
};
|
||||
|
||||
export type FirstBattleCampFollowupResolution =
|
||||
| FirstBattleCampFollowup
|
||||
| FirstBattleCampFollowupFallback;
|
||||
|
||||
type FollowupVoice = Pick<
|
||||
FirstBattleCampFollowup,
|
||||
| 'mentorUnitId'
|
||||
| 'mentorName'
|
||||
| 'dialogueTitle'
|
||||
| 'dialogueLines'
|
||||
>;
|
||||
|
||||
const firstBattleCriterionLabels: Record<
|
||||
FirstBattleCampFocusCriterionId,
|
||||
string
|
||||
> = {
|
||||
'elite-grade': '편성 평가 A 이상',
|
||||
'elite-survival': '출전 전원 생존',
|
||||
'mobile-quick': '제한 턴 이내 승리',
|
||||
'mobile-breakthrough': '돌파 기여 1회',
|
||||
'siege-objective': '거점·보너스 목표 달성',
|
||||
'siege-front': '전열 기여 1 이상',
|
||||
'siege-support': '후원 기여 1 이상'
|
||||
};
|
||||
|
||||
const targetDialogueIdByOrder: Record<
|
||||
CampaignSortieOrderId,
|
||||
FirstBattleCampFollowup['targetDialogueId']
|
||||
> = {
|
||||
elite: 'liu-guan-after-first-battle',
|
||||
mobile: 'liu-zhang-after-first-battle',
|
||||
siege: 'guan-zhang-after-first-battle'
|
||||
};
|
||||
|
||||
const missedCriterionVoices: Record<
|
||||
FirstBattleCampFocusCriterionId,
|
||||
FollowupVoice
|
||||
> = {
|
||||
'elite-grade': {
|
||||
mentorUnitId: 'liu-bei',
|
||||
mentorName: '유비',
|
||||
dialogueTitle: '승리의 모양을 다듬다',
|
||||
dialogueLines: [
|
||||
'유비: 승리는 거두었지만 병력과 역할의 짜임은 더 다듬어야 하오.',
|
||||
'관우: 다음에는 각자의 자리를 먼저 정하고 서로의 빈틈을 메우겠습니다.',
|
||||
'유비: 같은 군령으로 편성 평가부터 바로잡아 봅시다.'
|
||||
]
|
||||
},
|
||||
'elite-survival': {
|
||||
mentorUnitId: 'guan-yu',
|
||||
mentorName: '관우',
|
||||
dialogueTitle: '함께 돌아오는 승리',
|
||||
dialogueLines: [
|
||||
'관우: 이겼다 해도 한 사람을 두고 돌아오면 온전한 승리가 아닙니다.',
|
||||
'유비: 앞선 자와 뒤처진 자를 함께 살피는 전열이 필요하겠소.',
|
||||
'관우: 다음에는 제가 퇴로까지 지켜 모두 데려오겠습니다.'
|
||||
]
|
||||
},
|
||||
'mobile-quick': {
|
||||
mentorUnitId: 'zhang-fei',
|
||||
mentorName: '장비',
|
||||
dialogueTitle: '한 걸음 빠른 결착',
|
||||
dialogueLines: [
|
||||
'장비: 이겼지만 놈들에게 숨 돌릴 틈을 너무 오래 줬습니다.',
|
||||
'유비: 빠름은 혼자 앞서는 것이 아니라 군 전체가 함께 닿는 것이오.',
|
||||
'장비: 좋소! 다음에는 깃발을 보며 더 짧게 끝내겠습니다.'
|
||||
]
|
||||
},
|
||||
'mobile-breakthrough': {
|
||||
mentorUnitId: 'zhang-fei',
|
||||
mentorName: '장비',
|
||||
dialogueTitle: '막힌 곳을 여는 창',
|
||||
dialogueLines: [
|
||||
'장비: 발은 빨랐지만 정작 적진의 숨통을 끊는 한 방이 모자랐습니다.',
|
||||
'유비: 측면이 길을 열 때 전열도 함께 움직일 수 있소.',
|
||||
'장비: 다음에는 돌파 역할로 놈들의 빈틈부터 깨뜨리겠습니다.'
|
||||
]
|
||||
},
|
||||
'siege-objective': {
|
||||
mentorUnitId: 'guan-yu',
|
||||
mentorName: '관우',
|
||||
dialogueTitle: '싸움 너머의 목적',
|
||||
dialogueLines: [
|
||||
'관우: 적을 베는 데 몰두해 정작 지켜야 할 곳을 놓쳤다.',
|
||||
'장비: 다음에는 창을 들기 전에 어디를 지킬지부터 보겠습니다.',
|
||||
'관우: 좋다. 거점과 백성을 지키는 승리로 바로잡자.'
|
||||
]
|
||||
},
|
||||
'siege-front': {
|
||||
mentorUnitId: 'guan-yu',
|
||||
mentorName: '관우',
|
||||
dialogueTitle: '흔들리지 않는 전열',
|
||||
dialogueLines: [
|
||||
'관우: 앞줄이 버텨야 뒤의 계책과 도움이 제때 닿습니다.',
|
||||
'장비: 강한 한 사람보다 서로 이어진 전열이 필요하다는 말씀이군요.',
|
||||
'관우: 다음에는 내가 정면을 붙들 테니 네가 빈틈을 메워라.'
|
||||
]
|
||||
},
|
||||
'siege-support': {
|
||||
mentorUnitId: 'guan-yu',
|
||||
mentorName: '관우',
|
||||
dialogueTitle: '뒤에서 완성하는 승리',
|
||||
dialogueLines: [
|
||||
'관우: 앞의 공만 좇으면 뒤에서 살리고 돕는 손길을 놓치게 된다.',
|
||||
'장비: 후원이 이어져야 저도 앞에서 더 오래 버틸 수 있겠군요.',
|
||||
'관우: 다음에는 돕는 역할까지 우리 모두의 공으로 묶자.'
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
const achievedOrderVoices: Record<CampaignSortieOrderId, FollowupVoice> = {
|
||||
elite: {
|
||||
mentorUnitId: 'guan-yu',
|
||||
mentorName: '관우',
|
||||
dialogueTitle: '단단함 다음의 속도',
|
||||
dialogueLines: [
|
||||
'관우: 정예 군령은 지켰으니, 잔당이 흩어지기 전에 발을 재촉해야 합니다.',
|
||||
'유비: 좋소. 다만 모두의 보조가 맞아야 참된 기동이 되오.',
|
||||
'관우: 다음에는 전열을 흐트러뜨리지 않고 빠르게 추격하겠습니다.'
|
||||
]
|
||||
},
|
||||
mobile: {
|
||||
mentorUnitId: 'zhang-fei',
|
||||
mentorName: '장비',
|
||||
dialogueTitle: '익힌 속도를 추격으로',
|
||||
dialogueLines: [
|
||||
'장비: 빠르게 끝내는 법을 익혔으니 잔당 추격에도 딱 맞겠습니다!',
|
||||
'유비: 익힌 속도를 유지하되 백성과 대열을 지나치지 마시오.',
|
||||
'장비: 깃발이 보이는 거리에서 놈들의 퇴로를 끊겠습니다.'
|
||||
]
|
||||
},
|
||||
siege: {
|
||||
mentorUnitId: 'guan-yu',
|
||||
mentorName: '관우',
|
||||
dialogueTitle: '거점의 호흡을 추격으로',
|
||||
dialogueLines: [
|
||||
'관우: 거점과 역할을 함께 지킨 호흡을 이제 빠른 추격으로 옮겨야 한다.',
|
||||
'장비: 형님이 대열을 잡아 주면 제가 앞에서 퇴로를 끊겠습니다.',
|
||||
'관우: 좋다. 흩어지지 않는 기동으로 잔당을 놓치지 말자.'
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
export function resolveFirstBattleCampFollowup(
|
||||
report?: FirstBattleNarrativeReport | null
|
||||
): FirstBattleCampFollowupResolution {
|
||||
const memory = resolveFirstBattleVictoryNarrative([], report).memory;
|
||||
if (memory.source === 'fallback') {
|
||||
return {
|
||||
source: 'fallback',
|
||||
fallbackReason: memory.fallbackReason ?? 'invalid-order-progress'
|
||||
};
|
||||
}
|
||||
|
||||
const orderId = memory.orderId;
|
||||
const orderLabel = memory.orderLabel;
|
||||
if (!orderId || !orderLabel || memory.achieved === null) {
|
||||
return invalidOrderFallback();
|
||||
}
|
||||
|
||||
const victoryProgress = memory.progress.find((entry) => entry.id === 'victory');
|
||||
const missedCriterionId = memory.missedCriterionIds.find(
|
||||
(id): id is FirstBattleCampFocusCriterionId => id !== 'victory'
|
||||
);
|
||||
const internallyConsistent =
|
||||
victoryProgress?.achieved === true &&
|
||||
(memory.achieved ? !missedCriterionId : Boolean(missedCriterionId));
|
||||
if (!internallyConsistent) {
|
||||
return invalidOrderFallback();
|
||||
}
|
||||
|
||||
const recommendedNextOrderId = memory.achieved ? 'mobile' : orderId;
|
||||
const focusCriterionId = memory.achieved
|
||||
? 'mobile-quick'
|
||||
: missedCriterionId!;
|
||||
const focusCriterionLabel = firstBattleCriterionLabels[focusCriterionId];
|
||||
const voice = memory.achieved
|
||||
? achievedOrderVoices[orderId]
|
||||
: missedCriterionVoices[focusCriterionId];
|
||||
const recommendedNextOrderLabel =
|
||||
sortieOrderDefinition(recommendedNextOrderId).label;
|
||||
|
||||
return {
|
||||
source: 'campaign',
|
||||
fallbackReason: null,
|
||||
originalOrderId: orderId,
|
||||
originalOrderLabel: orderLabel,
|
||||
achieved: memory.achieved,
|
||||
focusCriterionId,
|
||||
focusCriterionLabel,
|
||||
...voice,
|
||||
targetDialogueId: targetDialogueIdByOrder[orderId],
|
||||
recommendedNextOrderId,
|
||||
recommendedNextOrderLabel,
|
||||
recommendationTitle: memory.achieved
|
||||
? `${recommendedNextOrderLabel} 군령으로 잔당 추격`
|
||||
: `${recommendedNextOrderLabel} 군령 재도전`,
|
||||
recommendationReason: memory.achieved
|
||||
? `${orderLabel} 군령 완수. 다음 잔당 추격전에서는 ${recommendedNextOrderLabel} 군령으로 ${focusCriterionLabel}에 도전합니다.`
|
||||
: `첫 승리에서 놓친 과제: ${focusCriterionLabel}. 같은 ${recommendedNextOrderLabel} 군령으로 다시 도전합니다.`,
|
||||
statusLabel: memory.achieved
|
||||
? '완수 · 추격전 적용'
|
||||
: `보완 필요 · ${focusCriterionLabel}`
|
||||
};
|
||||
}
|
||||
|
||||
function invalidOrderFallback(): FirstBattleCampFollowupFallback {
|
||||
return {
|
||||
source: 'fallback',
|
||||
fallbackReason: 'invalid-order-progress'
|
||||
};
|
||||
}
|
||||
@@ -24,6 +24,10 @@ import { getUnitClass, terrainRules, type TerrainType, type UnitClassKey } from
|
||||
import { defaultBattleScenario, getBattleScenario, type BattleScenarioDefinition, type BattleScenarioId } from '../data/battles';
|
||||
import { getSortieFlow, type SortieFlow } from '../data/campaignFlow';
|
||||
import { getCampSoundscape, type CampSoundscape } from '../data/campSoundscapes';
|
||||
import {
|
||||
resolveFirstBattleCampFollowup,
|
||||
type FirstBattleCampFollowup
|
||||
} from '../data/firstBattleCampFollowup';
|
||||
import {
|
||||
findCityStayAfterBattle,
|
||||
type CityEquipmentOffer,
|
||||
@@ -11353,6 +11357,9 @@ export class CampScene extends Phaser.Scene {
|
||||
private cityDialogueChoiceButtons: Phaser.GameObjects.Rectangle[] = [];
|
||||
private cityEquipmentOfferViews: CityEquipmentOfferView[] = [];
|
||||
private selectedDialogueId = campDialogues[0].id;
|
||||
private campDialogueRowButtons: Record<string, Phaser.GameObjects.Rectangle> = {};
|
||||
private campDialogueChoiceButtons: Record<string, Phaser.GameObjects.Rectangle> = {};
|
||||
private campDialogueBodyText?: Phaser.GameObjects.Text;
|
||||
private selectedVisitId = campVisits[0].id;
|
||||
private equipmentInventoryPage = 0;
|
||||
private equipmentPanelBackground?: Phaser.GameObjects.Rectangle;
|
||||
@@ -11399,6 +11406,9 @@ export class CampScene extends Phaser.Scene {
|
||||
private sortiePrimaryActionButton?: CampActionButtonView;
|
||||
private sortieNextActionGuideBackground?: Phaser.GameObjects.Rectangle;
|
||||
private sortieNextActionGuideText?: Phaser.GameObjects.Text;
|
||||
private firstBattleFollowupCtaButton?: Phaser.GameObjects.Rectangle;
|
||||
private firstBattleFollowupCard?: Phaser.GameObjects.Rectangle;
|
||||
private firstBattleFollowupSummaryText?: Phaser.GameObjects.Text;
|
||||
private visitedTabs = new Set<CampTab>();
|
||||
private selectedSortieUnitIds: string[] = [];
|
||||
private sortieFormationAssignments: SortieFormationAssignments = {};
|
||||
@@ -11471,6 +11481,9 @@ export class CampScene extends Phaser.Scene {
|
||||
this.cityInformationActionButton = undefined;
|
||||
this.cityDialogueChoiceButtons = [];
|
||||
this.cityEquipmentOfferViews = [];
|
||||
this.campDialogueRowButtons = {};
|
||||
this.campDialogueChoiceButtons = {};
|
||||
this.campDialogueBodyText = undefined;
|
||||
this.dialogueObjects = [];
|
||||
this.sortieObjects = [];
|
||||
this.sortieComparisonPanelView = undefined;
|
||||
@@ -11535,6 +11548,9 @@ export class CampScene extends Phaser.Scene {
|
||||
this.sortiePrimaryActionButton = undefined;
|
||||
this.sortieNextActionGuideBackground = undefined;
|
||||
this.sortieNextActionGuideText = undefined;
|
||||
this.firstBattleFollowupCtaButton = undefined;
|
||||
this.firstBattleFollowupCard = undefined;
|
||||
this.firstBattleFollowupSummaryText = undefined;
|
||||
this.visitedTabs = new Set(['status']);
|
||||
this.campaign = getCampaignState();
|
||||
this.report = this.campaign.firstBattleReport ?? getFirstBattleReport() ?? this.createFallbackReport();
|
||||
@@ -12783,11 +12799,42 @@ export class CampScene extends Phaser.Scene {
|
||||
);
|
||||
}
|
||||
|
||||
private firstBattleCampFollowup(): FirstBattleCampFollowup | undefined {
|
||||
if (!this.isFirstSortiePrepSlice()) {
|
||||
return undefined;
|
||||
}
|
||||
const resolution = resolveFirstBattleCampFollowup(this.report);
|
||||
return resolution.source === 'campaign' ? resolution : undefined;
|
||||
}
|
||||
|
||||
private campDialogueWithFirstBattleFollowup(dialogue: CampDialogue) {
|
||||
const followup = this.firstBattleCampFollowup();
|
||||
if (!followup || dialogue.id !== followup.targetDialogueId) {
|
||||
return dialogue;
|
||||
}
|
||||
return {
|
||||
...dialogue,
|
||||
title: followup.dialogueTitle,
|
||||
lines: [...followup.dialogueLines]
|
||||
};
|
||||
}
|
||||
|
||||
private hasPendingFirstBattleCampFollowup() {
|
||||
const followup = this.firstBattleCampFollowup();
|
||||
return Boolean(
|
||||
followup &&
|
||||
!this.completedCampDialogues().includes(followup.targetDialogueId)
|
||||
);
|
||||
}
|
||||
|
||||
private availableCampDialogues() {
|
||||
const battleId = this.currentCampBattleId();
|
||||
const battleDialogues = campDialogues.filter((dialogue) => dialogue.availableAfterBattleIds.includes(battleId));
|
||||
const dialogues = this.filterCampEventsByStep(battleDialogues);
|
||||
return dialogues.length > 0 ? dialogues : campDialogues.filter((dialogue) => dialogue.availableAfterBattleIds.includes(defaultBattleScenario.id));
|
||||
const available = dialogues.length > 0
|
||||
? dialogues
|
||||
: campDialogues.filter((dialogue) => dialogue.availableAfterBattleIds.includes(defaultBattleScenario.id));
|
||||
return available.map((dialogue) => this.campDialogueWithFirstBattleFollowup(dialogue));
|
||||
}
|
||||
|
||||
private completedAvailableDialogues() {
|
||||
@@ -13744,8 +13791,14 @@ export class CampScene extends Phaser.Scene {
|
||||
}));
|
||||
text.setOrigin(0.5);
|
||||
text.setInteractive({ useHandCursor: true });
|
||||
const badgeLabel = tab === 'city' ? '새' : width < 70 ? 'N' : 'NEW';
|
||||
const newBadge = tab === 'city' || tab === 'supplies' || tab === 'equipment'
|
||||
const badgeLabel = tab === 'city'
|
||||
? '새'
|
||||
: tab === 'dialogue'
|
||||
? '회고'
|
||||
: width < 70
|
||||
? 'N'
|
||||
: 'NEW';
|
||||
const newBadge = tab === 'city' || tab === 'dialogue' || tab === 'supplies' || tab === 'equipment'
|
||||
? this.scaleLegacyCampUi(this.add.text(x + width / 2 - 10, y - 17, badgeLabel, {
|
||||
...this.textStyle(9, '#fff2b8', true),
|
||||
backgroundColor: '#8a3f25',
|
||||
@@ -13779,6 +13832,7 @@ export class CampScene extends Phaser.Scene {
|
||||
indicator.setAlpha(active ? 1 : 0.66);
|
||||
newBadge?.setVisible(Boolean(
|
||||
(tab === 'city' && this.hasPendingCityStayActions()) ||
|
||||
(tab === 'dialogue' && this.hasPendingFirstBattleCampFollowup()) ||
|
||||
(tab === 'supplies' && pendingCategories.has('supplies')) ||
|
||||
(tab === 'equipment' && pendingCategories.has('equipment'))
|
||||
));
|
||||
@@ -14172,20 +14226,77 @@ export class CampScene extends Phaser.Scene {
|
||||
});
|
||||
|
||||
const commandY = y + height - 104;
|
||||
const firstBattleFollowup = firstSortie ? this.firstBattleCampFollowup() : undefined;
|
||||
const command = this.trackSortie(this.add.rectangle(x + 18, commandY, width - 36, 86, 0x211b14, 0.94));
|
||||
command.setOrigin(0);
|
||||
command.setDepth(depth + 1);
|
||||
command.setStrokeStyle(1, palette.gold, 0.6);
|
||||
this.trackSortie(this.add.text(x + 34, commandY + 12, '지휘관 판단', this.textStyle(14, '#ffdf7b', true))).setDepth(depth + 2);
|
||||
this.trackSortie(
|
||||
this.add.text(x + 34, commandY + 36, scenario.tacticalGuide?.summary ?? scenario.openingObjectiveLines[1] ?? (firstSortie
|
||||
if (firstBattleFollowup) {
|
||||
this.firstBattleFollowupCard = command;
|
||||
}
|
||||
const commandTitle = firstBattleFollowup
|
||||
? `지난 전투 인계 · ${firstBattleFollowup.mentorName}`
|
||||
: '지휘관 판단';
|
||||
this.trackSortie(this.add.text(x + 34, commandY + 12, commandTitle, this.textStyle(14, '#ffdf7b', true))).setDepth(depth + 2);
|
||||
const commandSummary = firstBattleFollowup
|
||||
? firstBattleFollowup.achieved
|
||||
? `${firstBattleFollowup.originalOrderLabel} 완수 → ${firstBattleFollowup.recommendedNextOrderLabel} · ${firstBattleFollowup.focusCriterionLabel}`
|
||||
: `${firstBattleFollowup.originalOrderLabel} 보완 → ${firstBattleFollowup.focusCriterionLabel}`
|
||||
: scenario.tacticalGuide?.summary ?? scenario.openingObjectiveLines[1] ?? (firstSortie
|
||||
? '세 형제가 서로 다른 길을 맡아 적의 전열을 흔드십시오.'
|
||||
: '전열·돌파·후원의 균형으로 적의 주력을 나누십시오.'), {
|
||||
...this.textStyle(13, '#d4dce6'),
|
||||
wordWrap: { width: width - 68, useAdvancedWrap: true },
|
||||
lineSpacing: 3
|
||||
: '전열·돌파·후원의 균형으로 적의 주력을 나누십시오.');
|
||||
const commandSummaryText = this.trackSortie(
|
||||
this.add.text(x + 34, commandY + 36, commandSummary, {
|
||||
...this.textStyle(firstBattleFollowup ? 11 : 13, '#d4dce6'),
|
||||
wordWrap: { width: firstBattleFollowup ? width - 214 : width - 68, useAdvancedWrap: true },
|
||||
lineSpacing: firstBattleFollowup ? 2 : 3
|
||||
})
|
||||
).setDepth(depth + 2);
|
||||
);
|
||||
commandSummaryText.setDepth(depth + 2);
|
||||
if (firstBattleFollowup) {
|
||||
this.firstBattleFollowupSummaryText = commandSummaryText;
|
||||
}
|
||||
if (firstBattleFollowup) {
|
||||
const buttonWidth = 132;
|
||||
const buttonX = x + width - 86;
|
||||
const buttonY = commandY + 51;
|
||||
const button = this.trackSortie(this.add.rectangle(buttonX, buttonY, buttonWidth, 34, 0x4a371d, 0.98));
|
||||
button.setDepth(depth + 2);
|
||||
button.setStrokeStyle(2, palette.gold, 0.92);
|
||||
button.setInteractive({ useHandCursor: true });
|
||||
button.on('pointerover', () => button.setFillStyle(0x654c25, 1));
|
||||
button.on('pointerout', () => button.setFillStyle(0x4a371d, 0.98));
|
||||
button.on('pointerdown', () => this.openFirstBattleFollowupOrderBrowser());
|
||||
this.firstBattleFollowupCtaButton = button;
|
||||
const label = this.trackSortie(
|
||||
this.add.text(
|
||||
buttonX,
|
||||
buttonY,
|
||||
`${firstBattleFollowup.recommendedNextOrderLabel} 군령 보기`,
|
||||
this.textStyle(11, '#fff2b8', true)
|
||||
)
|
||||
);
|
||||
label.setOrigin(0.5);
|
||||
label.setDepth(depth + 3);
|
||||
label.setInteractive({ useHandCursor: true });
|
||||
label.on('pointerover', () => button.setFillStyle(0x654c25, 1));
|
||||
label.on('pointerout', () => button.setFillStyle(0x4a371d, 0.98));
|
||||
label.on('pointerdown', () => this.openFirstBattleFollowupOrderBrowser());
|
||||
}
|
||||
}
|
||||
|
||||
private openFirstBattleFollowupOrderBrowser() {
|
||||
if (!this.firstBattleCampFollowup() || !this.nextSortieScenario()) {
|
||||
return;
|
||||
}
|
||||
this.sortiePrepStep = 'formation';
|
||||
this.closeSortieRecommendationBrowserState();
|
||||
this.closeSortiePresetBrowserState();
|
||||
this.sortieFormationPanelMode = 'order';
|
||||
this.sortieHoveredUnitId = undefined;
|
||||
this.sortiePinnedSwapCandidateUnitId = undefined;
|
||||
soundDirector.playSelect();
|
||||
this.showSortiePrep();
|
||||
}
|
||||
|
||||
private renderFirstSortieArtwork(x: number, y: number, width: number, height: number, depth: number, alpha: number) {
|
||||
@@ -14627,8 +14738,12 @@ export class CampScene extends Phaser.Scene {
|
||||
bg.setDepth(depth);
|
||||
bg.setStrokeStyle(1, palette.gold, 0.7);
|
||||
|
||||
const firstBattleFollowup = this.firstBattleCampFollowup();
|
||||
this.trackSortie(this.add.text(x + 18, y + 13, '작전 명령', this.textStyle(19, '#f2e3bf', true))).setDepth(depth + 1);
|
||||
this.trackSortie(this.add.text(x + 126, y + 18, '편성과 별개 · 전투 능력 보너스 없음', this.textStyle(10, '#9fb0bf', true)))
|
||||
const orderBrowserHint = firstBattleFollowup
|
||||
? `지난 전투 ${firstBattleFollowup.statusLabel} · ${firstBattleFollowup.recommendationTitle}`
|
||||
: '편성과 별개 · 전투 능력 보너스 없음';
|
||||
this.trackSortie(this.add.text(x + 126, y + 18, this.compactText(orderBrowserHint, 48), this.textStyle(10, '#9fb0bf', true)))
|
||||
.setDepth(depth + 1);
|
||||
|
||||
const closeButton = this.trackSortie(this.add.rectangle(x + width - 96, y + 9, 78, 30, 0x18232e, 0.96));
|
||||
@@ -14655,17 +14770,19 @@ export class CampScene extends Phaser.Scene {
|
||||
sortieOrderDefinitions.forEach((definition, index) => {
|
||||
const cardY = y + 50 + index * (cardHeight + cardGap);
|
||||
const selected = selectedOrderId === definition.id;
|
||||
const recommended = firstBattleFollowup?.recommendedNextOrderId === definition.id;
|
||||
const summary = summaries.get(definition.id);
|
||||
const currentResult = currentBattleResults?.[definition.id];
|
||||
const accent = this.sortiePresetDefinition(definition.id).accent;
|
||||
const fill = selected ? 0x1d3028 : currentResult?.achieved ? 0x172a22 : 0x151f2a;
|
||||
const fill = selected ? 0x1d3028 : recommended ? 0x2a2419 : currentResult?.achieved ? 0x172a22 : 0x151f2a;
|
||||
const stroke = selected ? palette.gold : recommended ? palette.gold : currentResult?.achieved ? palette.green : accent;
|
||||
const card = this.trackSortie(this.add.rectangle(cardX, cardY, cardWidth, cardHeight, fill, 0.98));
|
||||
card.setOrigin(0);
|
||||
card.setDepth(depth + 1);
|
||||
card.setStrokeStyle(selected ? 2 : 1, selected ? palette.gold : currentResult?.achieved ? palette.green : accent, selected ? 0.96 : 0.52);
|
||||
card.setStrokeStyle(selected || recommended ? 2 : 1, stroke, selected ? 0.96 : recommended ? 0.84 : 0.52);
|
||||
card.setInteractive({ useHandCursor: true });
|
||||
card.on('pointerover', () => card.setFillStyle(selected ? 0x263d32 : 0x22303c, 1).setStrokeStyle(2, palette.gold, 0.96));
|
||||
card.on('pointerout', () => card.setFillStyle(fill, 0.98).setStrokeStyle(selected ? 2 : 1, selected ? palette.gold : currentResult?.achieved ? palette.green : accent, selected ? 0.96 : 0.52));
|
||||
card.on('pointerout', () => card.setFillStyle(fill, 0.98).setStrokeStyle(selected || recommended ? 2 : 1, stroke, selected ? 0.96 : recommended ? 0.84 : 0.52));
|
||||
card.on('pointerdown', () => this.selectSortieOrder(definition.id));
|
||||
cards[definition.id] = card;
|
||||
|
||||
@@ -14693,17 +14810,20 @@ export class CampScene extends Phaser.Scene {
|
||||
this.trackSortie(this.add.text(cardX + 22, cardY + 79, this.compactText(rewardLine, width > 650 ? 82 : 66), this.textStyle(10, '#ffdf7b', true)))
|
||||
.setDepth(depth + 2);
|
||||
|
||||
const badgeLabel = selected ? '선택됨' : currentResult?.achieved ? '달성 기록' : '선택';
|
||||
const badge = this.trackSortie(this.add.rectangle(cardX + cardWidth - 82, cardY + 9, 66, 22, selected ? 0x304333 : 0x18232e, 0.98));
|
||||
const badgeLabel = selected ? '선택됨' : recommended ? '회고 추천' : currentResult?.achieved ? '달성 기록' : '선택';
|
||||
const badge = this.trackSortie(this.add.rectangle(cardX + cardWidth - 82, cardY + 9, 66, 22, selected ? 0x304333 : recommended ? 0x4a371d : 0x18232e, 0.98));
|
||||
badge.setOrigin(0);
|
||||
badge.setDepth(depth + 3);
|
||||
badge.setStrokeStyle(1, selected ? palette.green : accent, selected ? 0.86 : 0.56);
|
||||
this.trackSortie(this.add.text(cardX + cardWidth - 49, cardY + 20, badgeLabel, this.textStyle(9, selected ? '#e7ffd9' : '#d8ecff', true)))
|
||||
badge.setStrokeStyle(1, selected ? palette.green : recommended ? palette.gold : accent, selected ? 0.86 : recommended ? 0.78 : 0.56);
|
||||
this.trackSortie(this.add.text(cardX + cardWidth - 49, cardY + 20, badgeLabel, this.textStyle(9, selected ? '#e7ffd9' : recommended ? '#fff2b8' : '#d8ecff', true)))
|
||||
.setOrigin(0.5)
|
||||
.setDepth(depth + 4);
|
||||
});
|
||||
|
||||
this.trackSortie(this.add.text(x + 18, y + height - 24, '명령 선택은 도전 목표·전적·최초 보상만 바꾸며 편성책과 보급을 유지합니다.', this.textStyle(10, '#9fb0bf', true)))
|
||||
const footer = firstBattleFollowup
|
||||
? `회고 추천 · ${firstBattleFollowup.recommendationReason} · 선택 전에는 저장되지 않습니다.`
|
||||
: '명령 선택은 도전 목표·전적·최초 보상만 바꾸며 편성책과 보급을 유지합니다.';
|
||||
this.trackSortie(this.add.text(x + 18, y + height - 24, this.compactText(footer, width > 650 ? 92 : 72), this.textStyle(10, '#9fb0bf', true)))
|
||||
.setDepth(depth + 1);
|
||||
this.sortieOrderBrowserView = { closeButton, cards };
|
||||
}
|
||||
@@ -20173,6 +20293,9 @@ export class CampScene extends Phaser.Scene {
|
||||
this.sortiePrimaryActionButton = undefined;
|
||||
this.sortieNextActionGuideBackground = undefined;
|
||||
this.sortieNextActionGuideText = undefined;
|
||||
this.firstBattleFollowupCtaButton = undefined;
|
||||
this.firstBattleFollowupCard = undefined;
|
||||
this.firstBattleFollowupSummaryText = undefined;
|
||||
this.sortiePortraitRosterLayout = undefined;
|
||||
this.sortieRosterToggleBounds = [];
|
||||
this.sortieHoveredUnitId = undefined;
|
||||
@@ -22408,6 +22531,7 @@ export class CampScene extends Phaser.Scene {
|
||||
this.track(this.add.text(x + 24, y + 56, '출진 전 대화에서 선택지를 고르면 해당 장수들의 공명 경험치가 오릅니다.', this.textStyle(14, '#d4dce6')));
|
||||
|
||||
const dialogues = this.availableCampDialogues();
|
||||
const firstBattleFollowup = this.firstBattleCampFollowup();
|
||||
const compactDialogueList = dialogues.length > 3;
|
||||
const dialogueRowGap = compactDialogueList ? 35 : 64;
|
||||
const dialogueRowHeight = compactDialogueList ? 30 : 48;
|
||||
@@ -22423,12 +22547,31 @@ export class CampScene extends Phaser.Scene {
|
||||
row.setOrigin(0);
|
||||
row.setStrokeStyle(1, completed ? palette.green : selected ? palette.gold : palette.blue, selected ? 0.72 : 0.46);
|
||||
row.setInteractive({ useHandCursor: true });
|
||||
this.campDialogueRowButtons[dialogue.id] = row;
|
||||
row.on('pointerdown', () => {
|
||||
soundDirector.playSelect();
|
||||
this.selectedDialogueId = dialogue.id;
|
||||
this.render();
|
||||
});
|
||||
this.track(this.add.text(x + 38, rowY + (compactDialogueList ? 3 : 8), dialogue.title, this.textStyle(compactDialogueList ? 11 : 15, completed ? '#a8ffd0' : '#f2e3bf', true)));
|
||||
const isFirstBattleFollowup = firstBattleFollowup?.targetDialogueId === dialogue.id;
|
||||
const title = isFirstBattleFollowup
|
||||
? this.compactText(dialogue.title, compactDialogueList ? 20 : 14)
|
||||
: dialogue.title;
|
||||
this.track(this.add.text(x + 38, rowY + (compactDialogueList ? 3 : 8), title, this.textStyle(compactDialogueList ? 11 : 15, completed ? '#a8ffd0' : '#f2e3bf', true)));
|
||||
if (isFirstBattleFollowup) {
|
||||
this.track(
|
||||
this.add.text(
|
||||
x + 320,
|
||||
rowY + (compactDialogueList ? 4 : 9),
|
||||
completed ? '완료' : '회고',
|
||||
{
|
||||
...this.textStyle(compactDialogueList ? 8 : 9, completed ? '#a8ffd0' : '#fff2b8', true),
|
||||
backgroundColor: completed ? '#21402d' : '#8a3f25',
|
||||
padding: { x: 4, y: 2 }
|
||||
}
|
||||
)
|
||||
).setOrigin(1, 0);
|
||||
}
|
||||
const maxReward = dialogue.rewardExp + Math.max(...dialogue.choices.map((choice) => choice.rewardExp));
|
||||
this.track(this.add.text(x + 38, rowY + (compactDialogueList ? 17 : 29), `${this.unitName(dialogue.unitIds[0])} · ${this.unitName(dialogue.unitIds[1])} 공명 +${dialogue.rewardExp}~${maxReward}`, this.textStyle(compactDialogueList ? 9 : 12, '#9fb0bf')));
|
||||
});
|
||||
@@ -22453,13 +22596,14 @@ export class CampScene extends Phaser.Scene {
|
||||
bg.setOrigin(0);
|
||||
bg.setStrokeStyle(1, completed ? palette.green : palette.gold, 0.5);
|
||||
this.track(this.add.text(x + 18, y + 16, dialogue.title, this.textStyle(20, '#f2e3bf', true)));
|
||||
this.track(
|
||||
this.add.text(x + 18, y + 52, dialogue.lines.join('\n\n'), {
|
||||
...this.textStyle(15, '#d4dce6'),
|
||||
const dialogueBody = this.track(
|
||||
this.add.text(x + 18, y + 52, dialogue.lines.join('\n'), {
|
||||
...this.textStyle(14, '#d4dce6'),
|
||||
wordWrap: { width: width - 36, useAdvancedWrap: true },
|
||||
lineSpacing: 5
|
||||
lineSpacing: 3
|
||||
})
|
||||
);
|
||||
this.campDialogueBodyText = dialogueBody;
|
||||
|
||||
if (completed) {
|
||||
const chosen = this.recordedDialogueChoice(dialogue);
|
||||
@@ -22487,6 +22631,7 @@ export class CampScene extends Phaser.Scene {
|
||||
const button = this.track(this.add.rectangle(x + width / 2, choiceY, width - 36, 34, 0x1a2630, 0.96));
|
||||
button.setStrokeStyle(1, palette.gold, 0.76);
|
||||
button.setInteractive({ useHandCursor: true });
|
||||
this.campDialogueChoiceButtons[`${dialogue.id}:${choice.id}`] = button;
|
||||
button.on('pointerover', () => button.setFillStyle(0x283947, 0.98));
|
||||
button.on('pointerout', () => button.setFillStyle(0x1a2630, 0.96));
|
||||
button.on('pointerdown', () => this.completeDialogue(dialogue, choice));
|
||||
@@ -23730,7 +23875,11 @@ export class CampScene extends Phaser.Scene {
|
||||
}
|
||||
|
||||
private showDialogueReward(dialogue: CampDialogue, choice: CampDialogueChoice, rewardExp: number) {
|
||||
this.showCampNotice(`획득 내역 · ${choice.label} · 공명 +${rewardExp}\n응답 · ${choice.response}`);
|
||||
const followup = this.firstBattleCampFollowup();
|
||||
const preparationLine = followup?.targetDialogueId === dialogue.id
|
||||
? `\n다음 준비 · ${followup.recommendationTitle}`
|
||||
: '';
|
||||
this.showCampNotice(`획득 내역 · ${choice.label} · 공명 +${rewardExp}\n응답 · ${choice.response}${preparationLine}`);
|
||||
}
|
||||
|
||||
private showCampNotice(message: string) {
|
||||
@@ -24563,6 +24712,9 @@ export class CampScene extends Phaser.Scene {
|
||||
private clearContent() {
|
||||
this.contentObjects.forEach((object) => object.destroy());
|
||||
this.contentObjects = [];
|
||||
this.campDialogueRowButtons = {};
|
||||
this.campDialogueChoiceButtons = {};
|
||||
this.campDialogueBodyText = undefined;
|
||||
this.campRosterLayout = undefined;
|
||||
this.cityPanelBackground = undefined;
|
||||
this.cityExploreButton = undefined;
|
||||
@@ -24716,6 +24868,11 @@ export class CampScene extends Phaser.Scene {
|
||||
const cityDialogueComplete = cityStay
|
||||
? this.completedCampDialogues().includes(cityStay.dialogue.id)
|
||||
: false;
|
||||
const firstBattleCampFollowup = this.firstBattleCampFollowup();
|
||||
const availableCampDialogues = this.availableCampDialogues();
|
||||
const selectedCampDialogue = availableCampDialogues.find(
|
||||
(dialogue) => dialogue.id === this.selectedDialogueId
|
||||
) ?? availableCampDialogues[0];
|
||||
return {
|
||||
scene: this.scene.key,
|
||||
victoryRewardAcknowledgement: {
|
||||
@@ -24772,6 +24929,60 @@ export class CampScene extends Phaser.Scene {
|
||||
strokeColor: button.bg.strokeColor,
|
||||
lineWidth: button.bg.lineWidth
|
||||
})),
|
||||
firstVictoryFollowup: firstBattleCampFollowup
|
||||
? {
|
||||
available: true,
|
||||
sourceBattleId: this.report?.battleId ?? null,
|
||||
originalOrderId: firstBattleCampFollowup.originalOrderId,
|
||||
originalOrderLabel: firstBattleCampFollowup.originalOrderLabel,
|
||||
achieved: firstBattleCampFollowup.achieved,
|
||||
focusCriterionId: firstBattleCampFollowup.focusCriterionId,
|
||||
focusCriterionLabel: firstBattleCampFollowup.focusCriterionLabel,
|
||||
statusLabel: firstBattleCampFollowup.statusLabel,
|
||||
mentorUnitId: firstBattleCampFollowup.mentorUnitId,
|
||||
mentorName: firstBattleCampFollowup.mentorName,
|
||||
targetDialogueId: firstBattleCampFollowup.targetDialogueId,
|
||||
dialogueTitle: firstBattleCampFollowup.dialogueTitle,
|
||||
dialogueLines: [...firstBattleCampFollowup.dialogueLines],
|
||||
dialogueCompleted: this.completedCampDialogues().includes(firstBattleCampFollowup.targetDialogueId),
|
||||
dialogueRowBounds: this.sortieObjectBoundsDebug(
|
||||
this.campDialogueRowButtons[firstBattleCampFollowup.targetDialogueId]
|
||||
),
|
||||
recommendedOrderId: firstBattleCampFollowup.recommendedNextOrderId,
|
||||
recommendedOrderLabel: firstBattleCampFollowup.recommendedNextOrderLabel,
|
||||
recommendationTitle: firstBattleCampFollowup.recommendationTitle,
|
||||
recommendationReason: firstBattleCampFollowup.recommendationReason,
|
||||
handoffCardBounds: this.sortieObjectBoundsDebug(this.firstBattleFollowupCard),
|
||||
handoffSummaryBounds: this.sortieTextBoundsDebug(this.firstBattleFollowupSummaryText),
|
||||
ctaBounds: this.sortieInteractiveObjectBoundsDebug(this.firstBattleFollowupCtaButton),
|
||||
ctaInteractive: Boolean(this.firstBattleFollowupCtaButton?.input?.enabled),
|
||||
recommendedOrderSelected: this.currentSortieOrderId() === firstBattleCampFollowup.recommendedNextOrderId,
|
||||
recommendedOrderCardBounds: this.sortieObjectBoundsDebug(
|
||||
this.sortieOrderBrowserView?.cards[firstBattleCampFollowup.recommendedNextOrderId]
|
||||
)
|
||||
}
|
||||
: null,
|
||||
selectedDialogue: selectedCampDialogue
|
||||
? {
|
||||
id: selectedCampDialogue.id,
|
||||
title: selectedCampDialogue.title,
|
||||
unitIds: [...selectedCampDialogue.unitIds],
|
||||
lines: [...selectedCampDialogue.lines],
|
||||
completed: this.completedCampDialogues().includes(selectedCampDialogue.id),
|
||||
firstVictoryFollowup: firstBattleCampFollowup?.targetDialogueId === selectedCampDialogue.id,
|
||||
rowBounds: this.sortieObjectBoundsDebug(this.campDialogueRowButtons[selectedCampDialogue.id]),
|
||||
bodyBounds: this.sortieTextBoundsDebug(this.campDialogueBodyText),
|
||||
choices: selectedCampDialogue.choices.map((choice) => ({
|
||||
id: choice.id,
|
||||
label: choice.label,
|
||||
response: choice.response,
|
||||
rewardExp: selectedCampDialogue.rewardExp + choice.rewardExp,
|
||||
bounds: this.sortieInteractiveObjectBoundsDebug(
|
||||
this.campDialogueChoiceButtons[`${selectedCampDialogue.id}:${choice.id}`]
|
||||
)
|
||||
}))
|
||||
}
|
||||
: null,
|
||||
cityStay: cityStay
|
||||
? {
|
||||
available: true,
|
||||
@@ -24871,6 +25082,7 @@ export class CampScene extends Phaser.Scene {
|
||||
formationPanelMode: this.sortieFormationPanelMode,
|
||||
selectionBattleId: this.campaign?.sortieOrderSelection?.battleId ?? null,
|
||||
selectedId: sortieOrderId ?? null,
|
||||
recommendedId: firstBattleCampFollowup?.recommendedNextOrderId ?? null,
|
||||
complete: this.hasCurrentSortieOrderSelection(),
|
||||
toggleButtonBounds: this.sortieObjectBoundsDebug(this.sortieOrderToggleButton),
|
||||
closeButtonBounds: this.sortieObjectBoundsDebug(this.sortieOrderBrowserView?.closeButton),
|
||||
@@ -24884,6 +25096,7 @@ export class CampScene extends Phaser.Scene {
|
||||
summary: definition.summary,
|
||||
condition: definition.condition,
|
||||
selected: sortieOrderId === definition.id,
|
||||
recommended: firstBattleCampFollowup?.recommendedNextOrderId === definition.id,
|
||||
attempts: summary?.attempts ?? 0,
|
||||
successes: summary?.successes ?? 0,
|
||||
successRate: summary?.successRate ?? 0,
|
||||
|
||||
Reference in New Issue
Block a user