feat: add spoiler-safe campaign objective journal
This commit is contained in:
@@ -317,10 +317,41 @@ RPG로 다듬는다.
|
|||||||
전투 예보와 일치하는지를 Canvas와 WebGL 모두 1920x1080 CSS viewport,
|
전투 예보와 일치하는지를 Canvas와 WebGL 모두 1920x1080 CSS viewport,
|
||||||
100% 확대, DPR 1에서 자동 검증하는 계약으로 고정한다.
|
100% 확대, DPR 1에서 자동 검증하는 계약으로 고정한다.
|
||||||
|
|
||||||
다음 묶음은
|
### 완료한 16차 묶음: 길을 잃지 않고 미래를 미리 보지 않는 진군 기록
|
||||||
[Xbox 목표 명료성 지침](https://learn.microsoft.com/en-us/xbox/accessibility/xbox-accessibility-guidelines/109)에
|
|
||||||
맞춰 현재 목표·완료 목표·다음 목표를 어디에서 다시 확인할 수 있는지
|
- 현재 목표를 언제든 다시 보고 완료 기록을 구분해 보존하라는
|
||||||
캠페인 전반에서 감사한다.
|
[Xbox XAG 109](https://learn.microsoft.com/en-us/xbox/accessibility/xbox-accessibility-guidelines/109)와,
|
||||||
|
목표 알림을 상시·사용자 요청·장시간 비활동 가운데 알맞은 방식으로
|
||||||
|
제공하라는
|
||||||
|
[Game Accessibility Guidelines](https://gameaccessibilityguidelines.com/indicate-allow-reminder-of-current-objectives-during-gameplay/)를
|
||||||
|
공통 기록의 기준으로 삼았다.
|
||||||
|
- 다음 주 임무와 현재 임무 목록을 분리하고 완료 내역은 별도 기록에서
|
||||||
|
다시 보게 하는
|
||||||
|
[Final Fantasy XIV 공식 안내](https://na.finalfantasyxiv.com/game_manual/view/)와,
|
||||||
|
최근 사건·다음 단서·지금까지의 이야기를 서로 다른 기록으로 나누는
|
||||||
|
[Dragon Quest VII 공식 설명서](https://www.nintendo.com/eu/media/downloads/games_8/emanuals/nintendo_3ds_2/dragonquest7/ElectronicManual_Nintendo3DS_DragonQuest7_EN.pdf)를
|
||||||
|
정보 계층의 기준으로 적용했다.
|
||||||
|
- 캠페인 상태만으로 `◆ 진행`, `✓ 완료`, `? 실마리`를 계산하는 공통
|
||||||
|
진군 기록을 만들었다. 첫 마을·의용군 막사·승전 후일담·성 체류·전투
|
||||||
|
사이 탐색·출진 준비·엔딩까지 같은 규칙으로 최근 완료, 지금 할 일,
|
||||||
|
다음 단서를 연결한다.
|
||||||
|
- 마을·막사·성·막간 탐색과 이야기 화면에서는 상단 버튼이나 `J`로
|
||||||
|
진군 기록을 열고 `J`·`Esc`로 닫는다. 군영에서는 기존 연표를
|
||||||
|
`기록 J` 탭으로 바꾸고 같은 세 항목을 한 화면에서 보여 준다.
|
||||||
|
- 군영 기록은 도달한 큰 흐름과 현재 장만 공개한다. 시작 직후부터
|
||||||
|
형주 붕괴·백제성·남만·오장원 같은 먼 미래 장과 전장 조건을 보여 주던
|
||||||
|
스포일러를 제거하고, 패널 하단이 FHD 화면 밖으로 잘리던 배치도 함께
|
||||||
|
바로잡았다.
|
||||||
|
- 타이틀의 저장 기록은 막연한 캠페인 단계 대신 저장 당시의 `지금 할 일`을
|
||||||
|
요약한다. 전투 안의 승리 조건과 전술 목표는 기존 전투 HUD가 계속
|
||||||
|
담당해 캠페인 단서와 전술 지시를 섞지 않는다.
|
||||||
|
- 66개 전투 경계의 미래 정보 격리와 목표 순서를 정적으로 검사하고,
|
||||||
|
Canvas·WebGL의 1920x1080 CSS viewport, 100% 확대, DPR 1에서
|
||||||
|
포인터·J·Esc 재열기, 카드 경계·비겹침·저장 불변을 자동 검증한다.
|
||||||
|
|
||||||
|
다음 묶음은 탐색 도중 저장한 기록이 군영으로 되돌아가지 않도록
|
||||||
|
`activeCampVisitId`와 이어하기 요약을 보존하고, 저장 슬롯에서
|
||||||
|
`방금 마친 일 → 지금 할 일`까지 충분히 읽을 수 있는 재개 흐름을 다듬는다.
|
||||||
|
|
||||||
## 장기 개선 순서
|
## 장기 개선 순서
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,8 @@
|
|||||||
"verify:campaign-save": "node scripts/verify-campaign-save-normalization.mjs",
|
"verify:campaign-save": "node scripts/verify-campaign-save-normalization.mjs",
|
||||||
"verify:campaign-completion": "node scripts/verify-campaign-completion.mjs",
|
"verify:campaign-completion": "node scripts/verify-campaign-completion.mjs",
|
||||||
"verify:campaign-presentation": "node scripts/verify-campaign-presentation-profiles.mjs",
|
"verify:campaign-presentation": "node scripts/verify-campaign-presentation-profiles.mjs",
|
||||||
|
"verify:campaign-objectives": "node scripts/verify-campaign-objective-journal.mjs",
|
||||||
|
"verify:campaign-objectives:browser": "node scripts/verify-campaign-objective-journal-browser.mjs",
|
||||||
"verify:growth-consistency": "node scripts/verify-growth-consistency.mjs",
|
"verify:growth-consistency": "node scripts/verify-growth-consistency.mjs",
|
||||||
"verify:battle-save": "node scripts/verify-battle-save-normalization.mjs",
|
"verify:battle-save": "node scripts/verify-battle-save-normalization.mjs",
|
||||||
"verify:battle-usables": "node scripts/verify-battle-usables.mjs",
|
"verify:battle-usables": "node scripts/verify-battle-usables.mjs",
|
||||||
@@ -86,7 +88,7 @@
|
|||||||
"verify:title-accessibility:browser": "node scripts/verify-title-accessibility-browser.mjs",
|
"verify:title-accessibility:browser": "node scripts/verify-title-accessibility-browser.mjs",
|
||||||
"verify:save-flow": "node scripts/verify-save-retry-flow.mjs",
|
"verify:save-flow": "node scripts/verify-save-retry-flow.mjs",
|
||||||
"verify:release": "node scripts/verify-release-candidate.mjs",
|
"verify:release": "node scripts/verify-release-candidate.mjs",
|
||||||
"verify:local-release": "pnpm run verify:static-data && pnpm run verify:unit-action-assets && pnpm run verify:enemy-intent && tsc --noEmit && pnpm run build && pnpm run verify:unit-action-assets:dist && pnpm run verify:loader-lifecycle && pnpm run verify:interaction-ux && pnpm run verify:turn-end-risk:browser && pnpm run verify:title-accessibility:browser && pnpm run verify:battle-keyboard:browser && pnpm run verify:city-stays:browser && pnpm run verify:xuzhou-equipment-link:browser && pnpm run verify:xuzhou-stay-payoff:browser && pnpm run verify:prologue-village:browser && pnpm run verify:first-pursuit-camp:browser && pnpm run verify:second-battle-relief:browser && pnpm run verify:third-battle-return:browser && pnpm run verify:third-camp-preparation:browser && pnpm run verify:av-feedback:browser && pnpm run verify:release && pnpm run verify:performance",
|
"verify:local-release": "pnpm run verify:static-data && pnpm run verify:unit-action-assets && pnpm run verify:enemy-intent && tsc --noEmit && pnpm run build && pnpm run verify:unit-action-assets:dist && pnpm run verify:loader-lifecycle && pnpm run verify:interaction-ux && pnpm run verify:turn-end-risk:browser && pnpm run verify:campaign-objectives:browser && pnpm run verify:title-accessibility:browser && pnpm run verify:battle-keyboard:browser && pnpm run verify:city-stays:browser && pnpm run verify:xuzhou-equipment-link:browser && pnpm run verify:xuzhou-stay-payoff:browser && pnpm run verify:prologue-village:browser && pnpm run verify:first-pursuit-camp:browser && pnpm run verify:second-battle-relief:browser && pnpm run verify:third-battle-return:browser && pnpm run verify:third-camp-preparation:browser && pnpm run verify:av-feedback:browser && pnpm run verify:release && pnpm run verify:performance",
|
||||||
"verify:1.0": "pnpm run verify:local-release && pnpm run verify:flow && pnpm run qa:campaign-complete && pnpm run qa:battle-maps:webgl",
|
"verify:1.0": "pnpm run verify:local-release && pnpm run verify:flow && pnpm run qa:campaign-complete && pnpm run qa:battle-maps:webgl",
|
||||||
"verify:public-deploy": "node scripts/verify-public-deploy.mjs",
|
"verify:public-deploy": "node scripts/verify-public-deploy.mjs",
|
||||||
"qa:representative": "node scripts/qa-representative-battles.mjs",
|
"qa:representative": "node scripts/qa-representative-battles.mjs",
|
||||||
|
|||||||
@@ -1348,7 +1348,7 @@ async function setupBattle(page, battle) {
|
|||||||
return (
|
return (
|
||||||
state?.scene === 'BattleScene' &&
|
state?.scene === 'BattleScene' &&
|
||||||
state?.battleId === battleId &&
|
state?.battleId === battleId &&
|
||||||
(state?.phase === 'deployment' || state?.phase === 'idle') &&
|
state?.phase === 'deployment' &&
|
||||||
state?.mapBackgroundReady === true
|
state?.mapBackgroundReady === true
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -1536,7 +1536,7 @@ async function setupCumulativeBattle(page, battle, firstBattle, route) {
|
|||||||
return (
|
return (
|
||||||
state?.scene === 'BattleScene' &&
|
state?.scene === 'BattleScene' &&
|
||||||
state?.battleId === battleId &&
|
state?.battleId === battleId &&
|
||||||
(state?.phase === 'deployment' || state?.phase === 'idle') &&
|
state?.phase === 'deployment' &&
|
||||||
state?.mapBackgroundReady === true
|
state?.mapBackgroundReady === true
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -1649,7 +1649,9 @@ async function startDeploymentIfNeeded(page, battleId) {
|
|||||||
}, battleId);
|
}, battleId);
|
||||||
|
|
||||||
if (state?.phase !== 'deployment') {
|
if (state?.phase !== 'deployment') {
|
||||||
return;
|
throw new Error(
|
||||||
|
`Expected ${battleId} to expose deployment before battle start: ${JSON.stringify(state)}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await clickBattleDeploymentStart(page);
|
await clickBattleDeploymentStart(page);
|
||||||
@@ -1844,6 +1846,20 @@ async function enterCampaignCampFromBattleResult(page, battle, route) {
|
|||||||
settlement = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()?.resultSettlement);
|
settlement = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()?.resultSettlement);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => {
|
||||||
|
const resultSettlement = window.__HEROS_DEBUG__?.battle()?.resultSettlement;
|
||||||
|
return (
|
||||||
|
resultSettlement?.status === 'complete' &&
|
||||||
|
resultSettlement?.cta?.interactive === true &&
|
||||||
|
resultSettlement?.cta?.bounds
|
||||||
|
);
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
{ timeout: 30000 }
|
||||||
|
);
|
||||||
|
settlement = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()?.resultSettlement);
|
||||||
|
|
||||||
const expectedDestination = 'victory-story';
|
const expectedDestination = 'victory-story';
|
||||||
if (settlement?.cta?.destination !== expectedDestination) {
|
if (settlement?.cta?.destination !== expectedDestination) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -1915,6 +1931,21 @@ async function enterFinalCampFromBattleResult(page) {
|
|||||||
);
|
);
|
||||||
settlement = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()?.resultSettlement);
|
settlement = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()?.resultSettlement);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => {
|
||||||
|
const resultSettlement = window.__HEROS_DEBUG__?.battle()?.resultSettlement;
|
||||||
|
return (
|
||||||
|
resultSettlement?.status === 'complete' &&
|
||||||
|
resultSettlement?.cta?.interactive === true &&
|
||||||
|
resultSettlement?.cta?.bounds
|
||||||
|
);
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
{ timeout: 30000 }
|
||||||
|
);
|
||||||
|
settlement = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()?.resultSettlement);
|
||||||
|
|
||||||
if (settlement?.cta?.destination !== 'victory-story') {
|
if (settlement?.cta?.destination !== 'victory-story') {
|
||||||
throw new Error(`Final victory must continue through its aftermath before the final camp: ${JSON.stringify(settlement)}`);
|
throw new Error(`Final victory must continue through its aftermath before the final camp: ${JSON.stringify(settlement)}`);
|
||||||
}
|
}
|
||||||
|
|||||||
1540
scripts/verify-campaign-objective-journal-browser.mjs
Normal file
1540
scripts/verify-campaign-objective-journal-browser.mjs
Normal file
File diff suppressed because it is too large
Load Diff
619
scripts/verify-campaign-objective-journal.mjs
Normal file
619
scripts/verify-campaign-objective-journal.mjs
Normal file
@@ -0,0 +1,619 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import { createServer } from 'vite';
|
||||||
|
|
||||||
|
const server = await createServer({
|
||||||
|
logLevel: 'error',
|
||||||
|
server: { middlewareMode: true, hmr: false },
|
||||||
|
appType: 'custom'
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const journalModule = await server.ssrLoadModule(
|
||||||
|
'/src/game/state/campaignObjectiveJournal.ts'
|
||||||
|
);
|
||||||
|
const campaignModule = await server.ssrLoadModule(
|
||||||
|
'/src/game/state/campaignState.ts'
|
||||||
|
);
|
||||||
|
const routingModule = await server.ssrLoadModule(
|
||||||
|
'/src/game/state/campaignRouting.ts'
|
||||||
|
);
|
||||||
|
const { battleScenarios } = await server.ssrLoadModule(
|
||||||
|
'/src/game/data/battles.ts'
|
||||||
|
);
|
||||||
|
const { getSortieFlow } = await server.ssrLoadModule(
|
||||||
|
'/src/game/data/campaignFlow.ts'
|
||||||
|
);
|
||||||
|
const secondReliefModule = await server.ssrLoadModule(
|
||||||
|
'/src/game/data/secondBattleReliefExploration.ts'
|
||||||
|
);
|
||||||
|
const thirdExplorationModule = await server.ssrLoadModule(
|
||||||
|
'/src/game/data/thirdCampExploration.ts'
|
||||||
|
);
|
||||||
|
|
||||||
|
const resolve = journalModule.resolveCampaignObjectiveJournal;
|
||||||
|
const source = await readFile(
|
||||||
|
new URL(
|
||||||
|
'../src/game/state/campaignObjectiveJournal.ts',
|
||||||
|
import.meta.url
|
||||||
|
),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
verifyPureModuleContract(journalModule, source);
|
||||||
|
verifyInitialAndStoryStates(resolve, campaignModule);
|
||||||
|
verifyPrologueVillageProgress(resolve, campaignModule);
|
||||||
|
verifyPrologueCampProgress(resolve, campaignModule);
|
||||||
|
verifyAftermathIsolation(
|
||||||
|
resolve,
|
||||||
|
campaignModule,
|
||||||
|
battleScenarios,
|
||||||
|
getSortieFlow
|
||||||
|
);
|
||||||
|
verifyCityStayIsolation(
|
||||||
|
resolve,
|
||||||
|
campaignModule,
|
||||||
|
battleScenarios
|
||||||
|
);
|
||||||
|
verifyEarlyInterludes(
|
||||||
|
resolve,
|
||||||
|
campaignModule,
|
||||||
|
battleScenarios,
|
||||||
|
secondReliefModule,
|
||||||
|
thirdExplorationModule
|
||||||
|
);
|
||||||
|
verifyAllBattleAndCampRoutesHideFutureDetails(
|
||||||
|
resolve,
|
||||||
|
campaignModule,
|
||||||
|
routingModule,
|
||||||
|
battleScenarios,
|
||||||
|
getSortieFlow
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
'Campaign objective journal verification passed (current/completed/clue triplet, known/open progress, short resume context, early exploration sequencing, and future battle/title/condition isolation).'
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await server.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyPureModuleContract(journalModule, source) {
|
||||||
|
assert.deepEqual(journalModule.campaignObjectiveJournalStatusLabels, {
|
||||||
|
current: '◆ 진행',
|
||||||
|
completed: '✓ 완료',
|
||||||
|
clue: '? 실마리'
|
||||||
|
});
|
||||||
|
assert.doesNotMatch(
|
||||||
|
source,
|
||||||
|
/(?:from|import\s+type)\s+['"]phaser['"]/i,
|
||||||
|
'The objective journal must remain Phaser-independent.'
|
||||||
|
);
|
||||||
|
assert.doesNotMatch(
|
||||||
|
source,
|
||||||
|
/\.(?:victoryConditionLabel|defeatConditionLabel|openingObjectiveLines|objectives|units|leaderUnitId)\b/,
|
||||||
|
'The journal must not read battle win conditions, future units, or hidden tactical data.'
|
||||||
|
);
|
||||||
|
assert.doesNotMatch(
|
||||||
|
source,
|
||||||
|
/getSortieFlow\([^)]*\)\.(?:title|description|rewardHint)/,
|
||||||
|
'Campaign flow prose must not be copied into the spoiler-safe journal.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyInitialAndStoryStates(resolve, campaignModule) {
|
||||||
|
const initial = campaignModule.createInitialCampaignState();
|
||||||
|
const initialSnapshot = resolve(initial);
|
||||||
|
assertSnapshotShape(initialSnapshot);
|
||||||
|
assert.equal(initialSnapshot.contextKind, 'not-started');
|
||||||
|
assert.equal(initialSnapshot.current.statusLabel, '◆ 진행');
|
||||||
|
assert.equal(initialSnapshot.recentCompleted.statusLabel, '✓ 완료');
|
||||||
|
assert.equal(initialSnapshot.nextClue.statusLabel, '? 실마리');
|
||||||
|
assert.deepEqual(
|
||||||
|
pickProgress(initialSnapshot.progress),
|
||||||
|
{
|
||||||
|
completed: 0,
|
||||||
|
total: 1,
|
||||||
|
knownTotal: true
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const prologue = freshState(campaignModule, { step: 'prologue' });
|
||||||
|
const prologueSnapshot = resolve(prologue);
|
||||||
|
assert.equal(prologueSnapshot.contextKind, 'story');
|
||||||
|
assert.match(prologueSnapshot.current.title, /서막/);
|
||||||
|
assert.equal(prologueSnapshot.resumeContext.lines.length, 3);
|
||||||
|
assert(
|
||||||
|
prologueSnapshot.resumeContext.lines.every(
|
||||||
|
(line) => line.length <= 110
|
||||||
|
),
|
||||||
|
'Resume context lines must stay short enough for a compact recap card.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyPrologueVillageProgress(resolve, campaignModule) {
|
||||||
|
const ids = campaignModule.prologueVillageCampaignTutorialIds;
|
||||||
|
const village = freshState(campaignModule, {
|
||||||
|
step: 'prologue-town'
|
||||||
|
});
|
||||||
|
|
||||||
|
let snapshot = resolve(village);
|
||||||
|
assert.equal(snapshot.contextKind, 'village-exploration');
|
||||||
|
assert.equal(snapshot.current.title, '격문 앞의 장비');
|
||||||
|
assert.match(snapshot.nextClue.detail, /서쪽 모병 격문/);
|
||||||
|
assert.deepEqual(pickProgress(snapshot.progress), {
|
||||||
|
completed: 0,
|
||||||
|
total: 4,
|
||||||
|
knownTotal: true
|
||||||
|
});
|
||||||
|
|
||||||
|
village.completedTutorialIds.push(ids.meetZhangFei);
|
||||||
|
snapshot = resolve(village);
|
||||||
|
assert.equal(snapshot.current.title, '주점의 관우');
|
||||||
|
assert.equal(snapshot.recentCompleted.title, '격문 앞의 장비');
|
||||||
|
assert.equal(snapshot.progress.completed, 1);
|
||||||
|
|
||||||
|
village.completedTutorialIds.push(
|
||||||
|
ids.meetGuanYu,
|
||||||
|
ids.registerVolunteers
|
||||||
|
);
|
||||||
|
snapshot = resolve(village);
|
||||||
|
assert.equal(snapshot.current.title, '도원결의');
|
||||||
|
assert.match(snapshot.nextClue.detail, /복숭아 동산/);
|
||||||
|
assert.equal(snapshot.progress.completed, 3);
|
||||||
|
|
||||||
|
village.completedTutorialIds.push(ids.complete);
|
||||||
|
snapshot = resolve(village);
|
||||||
|
assert.match(snapshot.current.title, /결의 뒤 이야기/);
|
||||||
|
assert.equal(snapshot.recentCompleted.title, '도원결의');
|
||||||
|
assert.equal(snapshot.progress.completed, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyPrologueCampProgress(resolve, campaignModule) {
|
||||||
|
const ids = campaignModule.prologueMilitiaCampCampaignTutorialIds;
|
||||||
|
const camp = freshState(campaignModule, {
|
||||||
|
step: 'prologue-camp'
|
||||||
|
});
|
||||||
|
|
||||||
|
camp.completedTutorialIds.push(ids.inspectArms);
|
||||||
|
let snapshot = resolve(camp);
|
||||||
|
assert.equal(snapshot.contextKind, 'militia-camp-exploration');
|
||||||
|
assert.equal(snapshot.current.title, '관우의 정찰');
|
||||||
|
assert.equal(
|
||||||
|
snapshot.recentCompleted.title,
|
||||||
|
'병기·보급 점검',
|
||||||
|
'The recent completion must follow persisted insertion order, not fixed objective order.'
|
||||||
|
);
|
||||||
|
assert.equal(snapshot.progress.completed, 1);
|
||||||
|
|
||||||
|
camp.completedTutorialIds.push(
|
||||||
|
ids.readyVanguard,
|
||||||
|
ids.reviewScoutReport
|
||||||
|
);
|
||||||
|
snapshot = resolve(camp);
|
||||||
|
assert.equal(snapshot.current.title, '첫 출전 명령');
|
||||||
|
assert.match(snapshot.nextClue.detail, /북쪽 지휘 막사/);
|
||||||
|
assert.equal(snapshot.progress.completed, 3);
|
||||||
|
|
||||||
|
camp.completedTutorialIds.push(ids.complete);
|
||||||
|
snapshot = resolve(camp);
|
||||||
|
assert.match(snapshot.current.title, /출정 이야기/);
|
||||||
|
assert.equal(snapshot.recentCompleted.title, '첫 출전 군령');
|
||||||
|
assert.equal(snapshot.progress.completed, 4);
|
||||||
|
assert.doesNotMatch(
|
||||||
|
stringify(snapshot),
|
||||||
|
/한석 선봉 격퇴/,
|
||||||
|
'The first battle victory condition must not leak before the battlefield opens.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyAftermathIsolation(
|
||||||
|
resolve,
|
||||||
|
campaignModule,
|
||||||
|
battleScenarios,
|
||||||
|
getSortieFlow
|
||||||
|
) {
|
||||||
|
const battleId = 'first-battle-zhuo-commandery';
|
||||||
|
const state = campaignWithSettlement(
|
||||||
|
campaignModule,
|
||||||
|
battleScenarios,
|
||||||
|
battleId,
|
||||||
|
'first-camp'
|
||||||
|
);
|
||||||
|
state.pendingAftermathBattleId = battleId;
|
||||||
|
|
||||||
|
const snapshot = resolve(state);
|
||||||
|
const flow = getSortieFlow(state.latestBattleId, state.step);
|
||||||
|
assert.equal(snapshot.contextKind, 'aftermath');
|
||||||
|
assert.match(snapshot.current.title, /승리 후 이야기/);
|
||||||
|
assert.equal(snapshot.recentCompleted.statusLabel, '✓ 완료');
|
||||||
|
assertHiddenFlowDetailsAbsent(snapshot, flow, battleScenarios);
|
||||||
|
assert.doesNotMatch(
|
||||||
|
stringify(snapshot),
|
||||||
|
new RegExp(escapeRegExp(battleScenarios[battleId].victoryConditionLabel))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyCityStayIsolation(
|
||||||
|
resolve,
|
||||||
|
campaignModule,
|
||||||
|
battleScenarios
|
||||||
|
) {
|
||||||
|
const sourceBattleId = 'seventh-battle-xuzhou-rescue';
|
||||||
|
const state = campaignWithSettlement(
|
||||||
|
campaignModule,
|
||||||
|
battleScenarios,
|
||||||
|
sourceBattleId,
|
||||||
|
'seventh-camp'
|
||||||
|
);
|
||||||
|
state.activeCityStayId = 'xuzhou';
|
||||||
|
|
||||||
|
let snapshot = resolve(state);
|
||||||
|
assert.equal(snapshot.contextKind, 'city-stay');
|
||||||
|
assert.match(snapshot.current.title, /서주.*현지 정보/);
|
||||||
|
assert.deepEqual(pickProgress(snapshot.progress), {
|
||||||
|
completed: 0,
|
||||||
|
total: 2,
|
||||||
|
knownTotal: true
|
||||||
|
});
|
||||||
|
assert.doesNotMatch(
|
||||||
|
stringify(snapshot),
|
||||||
|
new RegExp(
|
||||||
|
escapeRegExp(
|
||||||
|
battleScenarios['eighth-battle-xiaopei-supply-road'].title
|
||||||
|
)
|
||||||
|
),
|
||||||
|
'A city stay must not expose the title of its future battle.'
|
||||||
|
);
|
||||||
|
assert.doesNotMatch(
|
||||||
|
stringify(snapshot),
|
||||||
|
/소패 보급로 장부 확인|여포군 기병/,
|
||||||
|
'A city stay journal must not copy future battle briefing prose.'
|
||||||
|
);
|
||||||
|
|
||||||
|
state.completedCampVisits.push('city-xuzhou-xiaopei-ledger');
|
||||||
|
snapshot = resolve(state);
|
||||||
|
assert.match(snapshot.current.title, /동료와 대화/);
|
||||||
|
assert.equal(snapshot.recentCompleted.title, '현지 정보 확보');
|
||||||
|
assert.equal(snapshot.progress.completed, 1);
|
||||||
|
|
||||||
|
state.completedCampDialogues.push(
|
||||||
|
'city-xuzhou-liu-mi-supply-trust'
|
||||||
|
);
|
||||||
|
snapshot = resolve(state);
|
||||||
|
assert.match(snapshot.current.title, /군영으로 돌아가/);
|
||||||
|
assert.equal(snapshot.progress.completed, 2);
|
||||||
|
assert.match(snapshot.progress.label, /시장은 선택/);
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyEarlyInterludes(
|
||||||
|
resolve,
|
||||||
|
campaignModule,
|
||||||
|
battleScenarios,
|
||||||
|
secondReliefModule,
|
||||||
|
thirdExplorationModule
|
||||||
|
) {
|
||||||
|
const firstCamp = campaignWithSettlement(
|
||||||
|
campaignModule,
|
||||||
|
battleScenarios,
|
||||||
|
'first-battle-zhuo-commandery',
|
||||||
|
'first-camp'
|
||||||
|
);
|
||||||
|
let snapshot = resolve(firstCamp);
|
||||||
|
assert.equal(snapshot.contextKind, 'interlude-exploration');
|
||||||
|
assert.match(snapshot.current.title, /북문 정찰/);
|
||||||
|
assert.doesNotMatch(
|
||||||
|
stringify(snapshot),
|
||||||
|
new RegExp(
|
||||||
|
escapeRegExp(
|
||||||
|
battleScenarios['second-battle-yellow-turban-pursuit'].title
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
firstCamp.completedCampVisits.push('first-pursuit-scout-tent');
|
||||||
|
firstCamp.campVisitChoiceIds['first-pursuit-scout-tent'] =
|
||||||
|
'trace-river-ambush';
|
||||||
|
snapshot = resolve(firstCamp);
|
||||||
|
assert.equal(snapshot.progress.completed, 1);
|
||||||
|
assert.equal(
|
||||||
|
snapshot.recentCompleted.title,
|
||||||
|
'강가 매복 흔적을 쫓는다'
|
||||||
|
);
|
||||||
|
|
||||||
|
const secondCamp = campaignWithSettlement(
|
||||||
|
campaignModule,
|
||||||
|
battleScenarios,
|
||||||
|
secondReliefModule.secondBattleReliefSourceBattleId,
|
||||||
|
'second-camp'
|
||||||
|
);
|
||||||
|
snapshot = resolve(secondCamp);
|
||||||
|
assert.match(snapshot.current.title, /북쪽 마을/);
|
||||||
|
assert.equal(snapshot.progress.completed, 0);
|
||||||
|
|
||||||
|
const firstObjectiveId = 'reassure-northern-village';
|
||||||
|
secondCamp.completedCampVisits.push(
|
||||||
|
secondReliefModule.secondBattleReliefObjectiveProgressId(
|
||||||
|
firstObjectiveId
|
||||||
|
)
|
||||||
|
);
|
||||||
|
snapshot = resolve(secondCamp);
|
||||||
|
assert.match(snapshot.current.title, /나루 사공/);
|
||||||
|
assert.equal(snapshot.progress.completed, 1);
|
||||||
|
assert.match(snapshot.recentCompleted.title, /북쪽 마을/);
|
||||||
|
|
||||||
|
[
|
||||||
|
'question-ferry-witness',
|
||||||
|
'inspect-messenger-trail'
|
||||||
|
].forEach((objectiveId) => {
|
||||||
|
secondCamp.completedCampVisits.push(
|
||||||
|
secondReliefModule.secondBattleReliefObjectiveProgressId(
|
||||||
|
objectiveId
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
snapshot = resolve(secondCamp);
|
||||||
|
assert.match(snapshot.current.title, /다음 출정의 우선 방침/);
|
||||||
|
assert.doesNotMatch(
|
||||||
|
stringify(snapshot),
|
||||||
|
new RegExp(
|
||||||
|
escapeRegExp(
|
||||||
|
battleScenarios[
|
||||||
|
secondReliefModule.secondBattleReliefTargetBattleId
|
||||||
|
].title
|
||||||
|
)
|
||||||
|
),
|
||||||
|
'The final relief decision must use generic current-action wording instead of the future battle title.'
|
||||||
|
);
|
||||||
|
|
||||||
|
secondCamp.completedCampVisits.push(
|
||||||
|
secondReliefModule.secondBattleReliefVisitId
|
||||||
|
);
|
||||||
|
secondCamp.campVisitChoiceIds[
|
||||||
|
secondReliefModule.secondBattleReliefVisitId
|
||||||
|
] = 'restore-northern-village';
|
||||||
|
snapshot = resolve(secondCamp);
|
||||||
|
assert.equal(snapshot.progress.completed, 4);
|
||||||
|
assert.equal(
|
||||||
|
snapshot.recentCompleted.title,
|
||||||
|
'북쪽 마을 보급선을 먼저 세운다'
|
||||||
|
);
|
||||||
|
|
||||||
|
const thirdCamp = campaignWithSettlement(
|
||||||
|
campaignModule,
|
||||||
|
battleScenarios,
|
||||||
|
thirdExplorationModule.thirdCampExplorationSourceBattleId,
|
||||||
|
'third-camp',
|
||||||
|
{
|
||||||
|
objectives: [
|
||||||
|
{ id: 'fort', achieved: true, status: 'done' },
|
||||||
|
{ id: 'quick', achieved: true, status: 'done' }
|
||||||
|
],
|
||||||
|
turnNumber: 22
|
||||||
|
}
|
||||||
|
);
|
||||||
|
thirdCamp.firstBattleReport = {
|
||||||
|
...minimalFirstBattleReport(
|
||||||
|
thirdCamp.battleHistory[
|
||||||
|
thirdExplorationModule.thirdCampExplorationSourceBattleId
|
||||||
|
]
|
||||||
|
)
|
||||||
|
};
|
||||||
|
snapshot = resolve(thirdCamp);
|
||||||
|
assert.equal(snapshot.contextKind, 'interlude-exploration');
|
||||||
|
assert.match(snapshot.current.detail, /모두 선택/);
|
||||||
|
assert.equal(snapshot.progress.total, 3);
|
||||||
|
assert.doesNotMatch(
|
||||||
|
stringify(snapshot),
|
||||||
|
new RegExp(
|
||||||
|
escapeRegExp(
|
||||||
|
battleScenarios[
|
||||||
|
thirdExplorationModule.thirdCampExplorationTargetBattleId
|
||||||
|
].title
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
thirdCamp.completedCampVisits.push(
|
||||||
|
thirdExplorationModule.thirdCampExplorationActivityProgressId(
|
||||||
|
'equipment'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
snapshot = resolve(thirdCamp);
|
||||||
|
assert.equal(snapshot.progress.completed, 1);
|
||||||
|
assert.equal(snapshot.recentCompleted.title, '장비 정비');
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyAllBattleAndCampRoutesHideFutureDetails(
|
||||||
|
resolve,
|
||||||
|
campaignModule,
|
||||||
|
routingModule,
|
||||||
|
battleScenarios,
|
||||||
|
getSortieFlow
|
||||||
|
) {
|
||||||
|
const routes = routingModule.campaignBattleRouteEntries();
|
||||||
|
routes.forEach(({ step, battleId }, index) => {
|
||||||
|
const battleState = freshState(campaignModule, { step });
|
||||||
|
const battleSnapshot = resolve(battleState);
|
||||||
|
assertSnapshotShape(battleSnapshot);
|
||||||
|
assert.equal(
|
||||||
|
battleSnapshot.current.title,
|
||||||
|
`${battleScenarios[battleId].title} 진행`
|
||||||
|
);
|
||||||
|
assert.doesNotMatch(
|
||||||
|
stringify(battleSnapshot),
|
||||||
|
new RegExp(
|
||||||
|
escapeRegExp(battleScenarios[battleId].victoryConditionLabel)
|
||||||
|
),
|
||||||
|
`Current battle ${battleId} must not expose its victory condition through the campaign journal.`
|
||||||
|
);
|
||||||
|
|
||||||
|
const nextRoute = routes[index + 1];
|
||||||
|
if (nextRoute) {
|
||||||
|
assert.doesNotMatch(
|
||||||
|
stringify(battleSnapshot),
|
||||||
|
new RegExp(
|
||||||
|
escapeRegExp(battleScenarios[nextRoute.battleId].title)
|
||||||
|
),
|
||||||
|
`Battle step ${step} must not expose future title ${nextRoute.battleId}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const campStep = step.replace(/-battle$/, '-camp');
|
||||||
|
const campState = campaignWithSettlement(
|
||||||
|
campaignModule,
|
||||||
|
battleScenarios,
|
||||||
|
battleId,
|
||||||
|
campStep
|
||||||
|
);
|
||||||
|
const campSnapshot = resolve(campState);
|
||||||
|
assertSnapshotShape(campSnapshot);
|
||||||
|
const flow = getSortieFlow(
|
||||||
|
campState.latestBattleId,
|
||||||
|
campState.step
|
||||||
|
);
|
||||||
|
assertHiddenFlowDetailsAbsent(
|
||||||
|
campSnapshot,
|
||||||
|
flow,
|
||||||
|
battleScenarios
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function freshState(campaignModule, overrides = {}) {
|
||||||
|
return Object.assign(
|
||||||
|
campaignModule.createInitialCampaignState(),
|
||||||
|
overrides
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function campaignWithSettlement(
|
||||||
|
campaignModule,
|
||||||
|
battleScenarios,
|
||||||
|
battleId,
|
||||||
|
step,
|
||||||
|
overrides = {}
|
||||||
|
) {
|
||||||
|
const state = freshState(campaignModule, { step });
|
||||||
|
const settlement = minimalSettlement(
|
||||||
|
battleScenarios[battleId],
|
||||||
|
overrides
|
||||||
|
);
|
||||||
|
state.latestBattleId = battleId;
|
||||||
|
state.battleHistory[battleId] = settlement;
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
function minimalSettlement(scenario, overrides = {}) {
|
||||||
|
return {
|
||||||
|
battleId: scenario.id,
|
||||||
|
battleTitle: scenario.title,
|
||||||
|
outcome: 'victory',
|
||||||
|
turnNumber: 12,
|
||||||
|
rewardGold: 0,
|
||||||
|
itemRewards: [],
|
||||||
|
objectives: [],
|
||||||
|
units: [],
|
||||||
|
bonds: [],
|
||||||
|
completedAt: '2026-07-28T00:00:00.000Z',
|
||||||
|
...overrides
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function minimalFirstBattleReport(settlement) {
|
||||||
|
return {
|
||||||
|
battleId: settlement.battleId,
|
||||||
|
battleTitle: settlement.battleTitle,
|
||||||
|
outcome: settlement.outcome,
|
||||||
|
turnNumber: settlement.turnNumber,
|
||||||
|
rewardGold: settlement.rewardGold,
|
||||||
|
defeatedEnemies: 1,
|
||||||
|
totalEnemies: 1,
|
||||||
|
objectives: structuredClone(settlement.objectives),
|
||||||
|
units: [],
|
||||||
|
bonds: [],
|
||||||
|
itemRewards: [],
|
||||||
|
completedCampDialogues: [],
|
||||||
|
completedCampVisits: [],
|
||||||
|
createdAt: settlement.completedAt
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertSnapshotShape(snapshot) {
|
||||||
|
assert(snapshot);
|
||||||
|
assert.equal(typeof snapshot.contextKind, 'string');
|
||||||
|
assertEntry(snapshot.current, '◆ 진행');
|
||||||
|
assertEntry(snapshot.recentCompleted, '✓ 완료');
|
||||||
|
assertEntry(snapshot.nextClue, '? 실마리');
|
||||||
|
assert.equal(typeof snapshot.progress.completed, 'number');
|
||||||
|
assert.equal(typeof snapshot.progress.knownTotal, 'boolean');
|
||||||
|
assert.equal(typeof snapshot.progress.label, 'string');
|
||||||
|
if (snapshot.progress.knownTotal) {
|
||||||
|
assert.equal(typeof snapshot.progress.total, 'number');
|
||||||
|
assert(snapshot.progress.completed <= snapshot.progress.total);
|
||||||
|
} else {
|
||||||
|
assert.equal(snapshot.progress.total, undefined);
|
||||||
|
}
|
||||||
|
assert.equal(typeof snapshot.resumeContext.title, 'string');
|
||||||
|
assert.equal(typeof snapshot.resumeContext.summary, 'string');
|
||||||
|
assert.equal(snapshot.resumeContext.lines.length, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertEntry(entry, expectedLabel) {
|
||||||
|
assert(entry);
|
||||||
|
assert.equal(entry.statusLabel, expectedLabel);
|
||||||
|
assert.equal(typeof entry.id, 'string');
|
||||||
|
assert(entry.id.length > 0);
|
||||||
|
assert.equal(typeof entry.title, 'string');
|
||||||
|
assert(entry.title.length > 0);
|
||||||
|
assert.equal(typeof entry.detail, 'string');
|
||||||
|
assert(entry.detail.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertHiddenFlowDetailsAbsent(
|
||||||
|
snapshot,
|
||||||
|
flow,
|
||||||
|
battleScenarios
|
||||||
|
) {
|
||||||
|
const serialized = stringify(snapshot);
|
||||||
|
if (!flow?.nextBattleId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const future = battleScenarios[flow.nextBattleId];
|
||||||
|
assert(future);
|
||||||
|
assert.doesNotMatch(
|
||||||
|
serialized,
|
||||||
|
new RegExp(escapeRegExp(future.title)),
|
||||||
|
`Future battle title ${future.title} must remain hidden.`
|
||||||
|
);
|
||||||
|
assert.doesNotMatch(
|
||||||
|
serialized,
|
||||||
|
new RegExp(escapeRegExp(future.victoryConditionLabel)),
|
||||||
|
`Future victory condition ${future.victoryConditionLabel} must remain hidden.`
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
serialized.includes(flow.description),
|
||||||
|
false,
|
||||||
|
'Future campaign-flow description must not be copied into the journal.'
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
serialized.includes(flow.rewardHint),
|
||||||
|
false,
|
||||||
|
'Future reward/recruit hints must not be copied into the journal.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickProgress(progress) {
|
||||||
|
return {
|
||||||
|
completed: progress.completed,
|
||||||
|
total: progress.total,
|
||||||
|
knownTotal: progress.knownTotal
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringify(value) {
|
||||||
|
return JSON.stringify(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeRegExp(value) {
|
||||||
|
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,7 @@ const checks = [
|
|||||||
'scripts/verify-campaign-save-normalization.mjs',
|
'scripts/verify-campaign-save-normalization.mjs',
|
||||||
'scripts/verify-campaign-completion.mjs',
|
'scripts/verify-campaign-completion.mjs',
|
||||||
'scripts/verify-campaign-presentation-profiles.mjs',
|
'scripts/verify-campaign-presentation-profiles.mjs',
|
||||||
|
'scripts/verify-campaign-objective-journal.mjs',
|
||||||
'scripts/verify-growth-consistency.mjs',
|
'scripts/verify-growth-consistency.mjs',
|
||||||
'scripts/verify-combat-presentation-settings.mjs',
|
'scripts/verify-combat-presentation-settings.mjs',
|
||||||
'scripts/verify-visual-motion-settings.mjs',
|
'scripts/verify-visual-motion-settings.mjs',
|
||||||
|
|||||||
@@ -259,6 +259,10 @@ import {
|
|||||||
type CampaignVictoryRewardReport,
|
type CampaignVictoryRewardReport,
|
||||||
type FirstBattleReport
|
type FirstBattleReport
|
||||||
} from '../state/campaignState';
|
} from '../state/campaignState';
|
||||||
|
import {
|
||||||
|
resolveCampaignObjectiveJournal,
|
||||||
|
type CampaignObjectiveJournalEntry
|
||||||
|
} from '../state/campaignObjectiveJournal';
|
||||||
import {
|
import {
|
||||||
chooseCityStayResonance,
|
chooseCityStayResonance,
|
||||||
collectCityStayInformation,
|
collectCityStayInformation,
|
||||||
@@ -356,6 +360,14 @@ type CampTabButtonView = {
|
|||||||
hovered: boolean;
|
hovered: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type CampObjectiveJournalCardView = {
|
||||||
|
kind: 'current' | 'completed' | 'next-clue';
|
||||||
|
background: Phaser.GameObjects.Rectangle;
|
||||||
|
status: Phaser.GameObjects.Text;
|
||||||
|
title: Phaser.GameObjects.Text;
|
||||||
|
detail: Phaser.GameObjects.Text;
|
||||||
|
};
|
||||||
|
|
||||||
type VictoryRewardCardId = CampaignVictoryRewardCategoryId;
|
type VictoryRewardCardId = CampaignVictoryRewardCategoryId;
|
||||||
|
|
||||||
type VictoryRewardActionId = 'equipment' | 'supplies' | 'sortie' | 'close';
|
type VictoryRewardActionId = 'equipment' | 'supplies' | 'sortie' | 'close';
|
||||||
@@ -11437,6 +11449,11 @@ export class CampScene extends Phaser.Scene {
|
|||||||
private campRosterPage = 0;
|
private campRosterPage = 0;
|
||||||
private campRosterLayout?: CampRosterLayout;
|
private campRosterLayout?: CampRosterLayout;
|
||||||
private activeTab: CampTab = 'status';
|
private activeTab: CampTab = 'status';
|
||||||
|
private campaignJournalReturnTab: Exclude<CampTab, 'progress'> = 'status';
|
||||||
|
private campaignJournalPanel?: Phaser.GameObjects.Rectangle;
|
||||||
|
private campaignJournalCardViews: CampObjectiveJournalCardView[] = [];
|
||||||
|
private campaignJournalVisibleText: string[] = [];
|
||||||
|
private campaignJournalVisibleChapterTitles: string[] = [];
|
||||||
private selectedCityLocation: CityStayLocationId = 'information';
|
private selectedCityLocation: CityStayLocationId = 'information';
|
||||||
private cityPanelBackground?: Phaser.GameObjects.Rectangle;
|
private cityPanelBackground?: Phaser.GameObjects.Rectangle;
|
||||||
private cityExploreButton?: Phaser.GameObjects.Rectangle;
|
private cityExploreButton?: Phaser.GameObjects.Rectangle;
|
||||||
@@ -11593,6 +11610,11 @@ export class CampScene extends Phaser.Scene {
|
|||||||
this.navigationPending = false;
|
this.navigationPending = false;
|
||||||
this.visualMotionReduced = isVisualMotionReduced();
|
this.visualMotionReduced = isVisualMotionReduced();
|
||||||
this.activeTab = 'status';
|
this.activeTab = 'status';
|
||||||
|
this.campaignJournalReturnTab = 'status';
|
||||||
|
this.campaignJournalPanel = undefined;
|
||||||
|
this.campaignJournalCardViews = [];
|
||||||
|
this.campaignJournalVisibleText = [];
|
||||||
|
this.campaignJournalVisibleChapterTitles = [];
|
||||||
this.selectedCityLocation = 'information';
|
this.selectedCityLocation = 'information';
|
||||||
this.campNoticeRemovalTimer = undefined;
|
this.campNoticeRemovalTimer = undefined;
|
||||||
this.contentObjects = [];
|
this.contentObjects = [];
|
||||||
@@ -11770,7 +11792,10 @@ export class CampScene extends Phaser.Scene {
|
|||||||
this.input.keyboard?.on('keydown-ESC', () => this.handleEscapeKey());
|
this.input.keyboard?.on('keydown-ESC', () => this.handleEscapeKey());
|
||||||
this.input.keyboard?.on('keydown-ENTER', (event: KeyboardEvent) => this.handleSortieEnterKey(event));
|
this.input.keyboard?.on('keydown-ENTER', (event: KeyboardEvent) => this.handleSortieEnterKey(event));
|
||||||
this.input.keyboard?.on('keydown', (event: KeyboardEvent) => {
|
this.input.keyboard?.on('keydown', (event: KeyboardEvent) => {
|
||||||
if (!this.handleThirdCampPreparationKey(event)) {
|
if (
|
||||||
|
!this.handleCampaignJournalKey(event) &&
|
||||||
|
!this.handleThirdCampPreparationKey(event)
|
||||||
|
) {
|
||||||
this.handleSaveSlotKey(event);
|
this.handleSaveSlotKey(event);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -13236,14 +13261,14 @@ export class CampScene extends Phaser.Scene {
|
|||||||
this.addTabButton('방문', 'visit', 758, 38, 60, 14);
|
this.addTabButton('방문', 'visit', 758, 38, 60, 14);
|
||||||
this.addTabButton('보급', 'supplies', 822, 38, 60, 14);
|
this.addTabButton('보급', 'supplies', 822, 38, 60, 14);
|
||||||
this.addTabButton('장비', 'equipment', 886, 38, 60, 14);
|
this.addTabButton('장비', 'equipment', 886, 38, 60, 14);
|
||||||
this.addTabButton('연표', 'progress', 950, 38, 60, 14);
|
this.addTabButton('기록 J', 'progress', 950, 38, 60, 13);
|
||||||
} else {
|
} else {
|
||||||
this.addTabButton('장수', 'status', 576, 38);
|
this.addTabButton('장수', 'status', 576, 38);
|
||||||
this.addTabButton('대화', 'dialogue', 650, 38);
|
this.addTabButton('대화', 'dialogue', 650, 38);
|
||||||
this.addTabButton('방문', 'visit', 724, 38);
|
this.addTabButton('방문', 'visit', 724, 38);
|
||||||
this.addTabButton('보급', 'supplies', 798, 38);
|
this.addTabButton('보급', 'supplies', 798, 38);
|
||||||
this.addTabButton('장비', 'equipment', 872, 38);
|
this.addTabButton('장비', 'equipment', 872, 38);
|
||||||
this.addTabButton('연표', 'progress', 946, 38);
|
this.addTabButton('기록 J', 'progress', 946, 38, 76, 14);
|
||||||
}
|
}
|
||||||
this.addCommandButton('저장', 1030, 38, 76, () => {
|
this.addCommandButton('저장', 1030, 38, 76, () => {
|
||||||
soundDirector.playSelect();
|
soundDirector.playSelect();
|
||||||
@@ -14118,6 +14143,38 @@ export class CampScene extends Phaser.Scene {
|
|||||||
return { key, ready: true };
|
return { key, ready: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private handleCampaignJournalKey(event: KeyboardEvent) {
|
||||||
|
if (event.key.toLowerCase() !== 'j') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (event.repeat) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
if (
|
||||||
|
this.navigationPending ||
|
||||||
|
this.victoryRewardObjects.length > 0 ||
|
||||||
|
this.equipmentSwapConfirmObjects.length > 0 ||
|
||||||
|
this.saveSlotObjects.length > 0 ||
|
||||||
|
this.saveSlotConfirmObjects.length > 0 ||
|
||||||
|
this.reportFormationReviewVisible ||
|
||||||
|
this.reportFormationHistoryVisible ||
|
||||||
|
this.sortieObjects.length > 0
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
soundDirector.playSelect();
|
||||||
|
if (this.activeTab === 'progress') {
|
||||||
|
this.activeTab = this.campaignJournalReturnTab;
|
||||||
|
} else {
|
||||||
|
this.clearCampNotice();
|
||||||
|
this.campaignJournalReturnTab = this.activeTab;
|
||||||
|
this.activeTab = 'progress';
|
||||||
|
}
|
||||||
|
this.render();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
private handleEscapeKey() {
|
private handleEscapeKey() {
|
||||||
if (this.navigationPending) {
|
if (this.navigationPending) {
|
||||||
return;
|
return;
|
||||||
@@ -14175,6 +14232,13 @@ export class CampScene extends Phaser.Scene {
|
|||||||
if (this.sortieObjects.length > 0) {
|
if (this.sortieObjects.length > 0) {
|
||||||
soundDirector.playSelect();
|
soundDirector.playSelect();
|
||||||
this.hideSortiePrep();
|
this.hideSortiePrep();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.activeTab === 'progress') {
|
||||||
|
soundDirector.playSelect();
|
||||||
|
this.activeTab = this.campaignJournalReturnTab;
|
||||||
|
this.render();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -14308,6 +14372,10 @@ export class CampScene extends Phaser.Scene {
|
|||||||
|
|
||||||
const selectTab = () => {
|
const selectTab = () => {
|
||||||
soundDirector.playSelect();
|
soundDirector.playSelect();
|
||||||
|
if (tab === 'progress' && this.activeTab !== 'progress') {
|
||||||
|
this.clearCampNotice();
|
||||||
|
this.campaignJournalReturnTab = this.activeTab;
|
||||||
|
}
|
||||||
this.activeTab = tab;
|
this.activeTab = tab;
|
||||||
this.render();
|
this.render();
|
||||||
};
|
};
|
||||||
@@ -23541,46 +23609,235 @@ export class CampScene extends Phaser.Scene {
|
|||||||
const width = 828;
|
const width = 828;
|
||||||
const height = 444;
|
const height = 444;
|
||||||
const progress = this.campaignTimelineProgress();
|
const progress = this.campaignTimelineProgress();
|
||||||
|
const journal = resolveCampaignObjectiveJournal(
|
||||||
|
this.campaign ?? getCampaignState()
|
||||||
|
);
|
||||||
const bg = this.track(this.add.rectangle(x, y, width, height, 0x101820, 0.9));
|
const bg = this.track(this.add.rectangle(x, y, width, height, 0x101820, 0.9));
|
||||||
bg.setOrigin(0);
|
bg.setOrigin(0);
|
||||||
bg.setStrokeStyle(1, palette.gold, 0.56);
|
bg.setStrokeStyle(1, palette.gold, 0.56);
|
||||||
|
this.campaignJournalPanel = bg;
|
||||||
|
|
||||||
this.track(this.add.text(x + 24, y + 22, '군영 연표', this.textStyle(24, '#f2e3bf', true)));
|
const title = this.track(this.add.text(x + 24, y + 18, '진군 기록', this.textStyle(24, '#f2e3bf', true)));
|
||||||
this.track(this.add.text(x + 24, y + 56, '유비군의 장기 서사와 현재 위치를 확인합니다.', this.textStyle(14, '#d4dce6')));
|
const subtitle = this.track(this.add.text(
|
||||||
|
x + 164,
|
||||||
|
y + 26,
|
||||||
|
'J · 지금 할 일과 이어질 실마리를 언제든 다시 확인',
|
||||||
|
this.textStyle(12, '#d4dce6', true)
|
||||||
|
));
|
||||||
|
const progressLabel = this.track(this.add.text(
|
||||||
|
x + width - 24,
|
||||||
|
y + 24,
|
||||||
|
`전장 ${progress.completedKnown}/${progress.totalKnown}`,
|
||||||
|
this.textStyle(13, '#fff2b8', true)
|
||||||
|
));
|
||||||
|
progressLabel.setOrigin(1, 0);
|
||||||
|
const ratio = progress.totalKnown > 0
|
||||||
|
? progress.completedKnown / progress.totalKnown
|
||||||
|
: 0;
|
||||||
|
this.drawBar(x + 24, y + 58, width - 48, 8, ratio, palette.gold);
|
||||||
|
|
||||||
const statY = y + 82;
|
this.campaignJournalVisibleText = [
|
||||||
this.renderProgressStatCard('완료 전장', `${progress.completedKnown}/${progress.totalKnown}`, x + 24, statY, 176);
|
title.text,
|
||||||
this.renderProgressStatCard('현재 장', progress.activeChapter.title, x + 212, statY, 198);
|
subtitle.text,
|
||||||
this.renderProgressStatCard('최근 전장', progress.latestBattleTitle, x + 422, statY, 176);
|
progressLabel.text
|
||||||
this.renderProgressStatCard('다음 전장', progress.nextBattleTitle, x + 610, statY, 190);
|
];
|
||||||
|
this.campaignJournalCardViews = [];
|
||||||
|
|
||||||
|
this.renderCampaignJournalCard(
|
||||||
|
'current',
|
||||||
|
journal.current,
|
||||||
|
x + 24,
|
||||||
|
y + 82,
|
||||||
|
width - 48,
|
||||||
|
92
|
||||||
|
);
|
||||||
|
this.renderCampaignJournalCard(
|
||||||
|
'completed',
|
||||||
|
journal.recentCompleted,
|
||||||
|
x + 24,
|
||||||
|
y + 186,
|
||||||
|
384,
|
||||||
|
104
|
||||||
|
);
|
||||||
|
this.renderCampaignJournalCard(
|
||||||
|
'next-clue',
|
||||||
|
journal.nextClue,
|
||||||
|
x + 420,
|
||||||
|
y + 186,
|
||||||
|
384,
|
||||||
|
104
|
||||||
|
);
|
||||||
|
|
||||||
const completedIds = this.completedBattleIds();
|
const completedIds = this.completedBattleIds();
|
||||||
const listX = x + 24;
|
const journeyX = x + 24;
|
||||||
const listY = y + 142;
|
const journeyY = y + 304;
|
||||||
const compactChapterList = campaignTimelineChapters.length > 9;
|
const journeyWidth = width - 48;
|
||||||
const chapterRowGap = compactChapterList ? 22 : 31;
|
const journeyHeight = 116;
|
||||||
const chapterRowHeight = compactChapterList ? 20 : 27;
|
const journeyBackground = this.track(
|
||||||
this.track(this.add.text(listX, listY, '큰 흐름', this.textStyle(17, '#f2e3bf', true)));
|
this.add.rectangle(
|
||||||
campaignTimelineChapters.forEach((chapter, index) => {
|
journeyX,
|
||||||
const status = this.timelineChapterStatus(chapter, index, progress.activeChapterIndex, completedIds);
|
journeyY,
|
||||||
const rowY = listY + 30 + index * chapterRowGap;
|
journeyWidth,
|
||||||
const active = index === progress.activeChapterIndex;
|
journeyHeight,
|
||||||
const complete = status === '완료';
|
0x0d141c,
|
||||||
const row = this.track(this.add.rectangle(listX, rowY, 360, chapterRowHeight, active ? 0x25384a : complete ? 0x17231d : 0x151f2a, active ? 0.96 : 0.84));
|
0.92
|
||||||
row.setOrigin(0);
|
)
|
||||||
row.setStrokeStyle(1, active ? palette.gold : complete ? palette.green : palette.blue, active ? 0.7 : 0.34);
|
);
|
||||||
const markerColor = active ? '#fff2b8' : complete ? '#a8ffd0' : '#9fb0bf';
|
journeyBackground.setOrigin(0);
|
||||||
const textY = rowY + (compactChapterList ? 2 : 5);
|
journeyBackground.setStrokeStyle(1, palette.blue, 0.42);
|
||||||
this.track(this.add.text(listX + 12, textY, status, this.textStyle(compactChapterList ? 10 : 11, markerColor, true)));
|
const journeyTitle = this.track(this.add.text(
|
||||||
this.track(this.add.text(listX + 64, rowY + (compactChapterList ? 1 : 4), chapter.title, this.textStyle(compactChapterList ? 11 : 13, active ? '#f2e3bf' : '#d4dce6', active)));
|
journeyX + 16,
|
||||||
const countText = chapter.battleIds.length > 0
|
journeyY + 12,
|
||||||
? `${chapter.battleIds.filter((id) => completedIds.has(id)).length}/${chapter.battleIds.length}`
|
'공개된 큰 흐름',
|
||||||
: '예정';
|
this.textStyle(13, '#f2e3bf', true)
|
||||||
const count = this.track(this.add.text(listX + 342, textY, countText, this.textStyle(compactChapterList ? 10 : 11, markerColor, true)));
|
));
|
||||||
count.setOrigin(1, 0);
|
const hiddenHint = this.track(this.add.text(
|
||||||
});
|
journeyX + journeyWidth - 16,
|
||||||
|
journeyY + 13,
|
||||||
|
'앞으로의 장은 도달할 때 공개됩니다.',
|
||||||
|
this.textStyle(11, '#9fb0bf', true)
|
||||||
|
));
|
||||||
|
hiddenHint.setOrigin(1, 0);
|
||||||
|
|
||||||
this.renderProgressChapterDetail(progress.activeChapter, progress.activeChapterIndex, x + 410, listY, 390, 310, completedIds);
|
const revealedChapters = campaignTimelineChapters
|
||||||
|
.slice(0, progress.activeChapterIndex + 1)
|
||||||
|
.slice(-3);
|
||||||
|
this.campaignJournalVisibleChapterTitles = revealedChapters.map(
|
||||||
|
(chapter) => chapter.title
|
||||||
|
);
|
||||||
|
this.campaignJournalVisibleText.push(
|
||||||
|
journeyTitle.text,
|
||||||
|
hiddenHint.text,
|
||||||
|
...this.campaignJournalVisibleChapterTitles
|
||||||
|
);
|
||||||
|
revealedChapters.forEach((chapter, index) => {
|
||||||
|
const absoluteIndex = campaignTimelineChapters.indexOf(chapter);
|
||||||
|
const active = absoluteIndex === progress.activeChapterIndex;
|
||||||
|
const complete = absoluteIndex < progress.activeChapterIndex;
|
||||||
|
const rowY = journeyY + 40 + index * 24;
|
||||||
|
const row = this.track(
|
||||||
|
this.add.rectangle(
|
||||||
|
journeyX + 16,
|
||||||
|
rowY,
|
||||||
|
journeyWidth - 32,
|
||||||
|
20,
|
||||||
|
active ? 0x25384a : 0x17231d,
|
||||||
|
0.9
|
||||||
|
)
|
||||||
|
);
|
||||||
|
row.setOrigin(0);
|
||||||
|
row.setStrokeStyle(
|
||||||
|
1,
|
||||||
|
active ? palette.gold : palette.green,
|
||||||
|
active ? 0.68 : 0.38
|
||||||
|
);
|
||||||
|
const status = active ? '◆ 현재' : complete ? '✓ 완료' : '• 공개';
|
||||||
|
this.track(this.add.text(
|
||||||
|
journeyX + 28,
|
||||||
|
rowY + 2,
|
||||||
|
status,
|
||||||
|
this.textStyle(10, active ? '#fff2b8' : '#a8ffd0', true)
|
||||||
|
));
|
||||||
|
this.track(this.add.text(
|
||||||
|
journeyX + 90,
|
||||||
|
rowY + 1,
|
||||||
|
chapter.title,
|
||||||
|
this.textStyle(11, '#d4dce6', active)
|
||||||
|
));
|
||||||
|
const chapterProgress = chapter.battleIds.length > 0
|
||||||
|
? `${chapter.battleIds.filter((id) => completedIds.has(id)).length}/${chapter.battleIds.length}`
|
||||||
|
: active
|
||||||
|
? '진행'
|
||||||
|
: '완료';
|
||||||
|
const count = this.track(this.add.text(
|
||||||
|
journeyX + journeyWidth - 28,
|
||||||
|
rowY + 2,
|
||||||
|
chapterProgress,
|
||||||
|
this.textStyle(10, active ? '#fff2b8' : '#a8ffd0', true)
|
||||||
|
));
|
||||||
|
count.setOrigin(1, 0);
|
||||||
|
this.campaignJournalVisibleText.push(status, chapterProgress);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderCampaignJournalCard(
|
||||||
|
kind: CampObjectiveJournalCardView['kind'],
|
||||||
|
entry: CampaignObjectiveJournalEntry,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
width: number,
|
||||||
|
height: number
|
||||||
|
) {
|
||||||
|
const cardStyle = kind === 'current'
|
||||||
|
? {
|
||||||
|
fill: 0x25384a,
|
||||||
|
stroke: palette.gold,
|
||||||
|
status: '◆ 진행 · 지금 할 일',
|
||||||
|
statusColor: '#fff2b8'
|
||||||
|
}
|
||||||
|
: kind === 'completed'
|
||||||
|
? {
|
||||||
|
fill: 0x17231d,
|
||||||
|
stroke: palette.green,
|
||||||
|
status: '✓ 완료 · 마친 일',
|
||||||
|
statusColor: '#a8ffd0'
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
fill: 0x1b2430,
|
||||||
|
stroke: palette.blue,
|
||||||
|
status: '? 실마리 · 다음 단서',
|
||||||
|
statusColor: '#b9d8f2'
|
||||||
|
};
|
||||||
|
const background = this.track(
|
||||||
|
this.add.rectangle(x, y, width, height, cardStyle.fill, 0.94)
|
||||||
|
);
|
||||||
|
background.setOrigin(0);
|
||||||
|
background.setStrokeStyle(
|
||||||
|
kind === 'current' ? 2 : 1,
|
||||||
|
cardStyle.stroke,
|
||||||
|
kind === 'current' ? 0.82 : 0.56
|
||||||
|
);
|
||||||
|
const status = this.track(this.add.text(
|
||||||
|
x + 16,
|
||||||
|
y + 10,
|
||||||
|
cardStyle.status,
|
||||||
|
this.textStyle(12, cardStyle.statusColor, true)
|
||||||
|
));
|
||||||
|
const locationText = entry.location ? ` · ${entry.location}` : '';
|
||||||
|
const title = this.track(this.add.text(
|
||||||
|
x + 16,
|
||||||
|
y + 32,
|
||||||
|
this.compactText(
|
||||||
|
`${entry.title}${locationText}`,
|
||||||
|
kind === 'current' ? 42 : 21
|
||||||
|
),
|
||||||
|
this.textStyle(kind === 'current' ? 17 : 15, '#f2e3bf', true)
|
||||||
|
));
|
||||||
|
const detail = this.track(this.add.text(
|
||||||
|
x + 16,
|
||||||
|
y + (kind === 'current' ? 58 : 59),
|
||||||
|
this.compactText(entry.detail, kind === 'current' ? 108 : 46),
|
||||||
|
{
|
||||||
|
...this.textStyle(12, '#d4dce6'),
|
||||||
|
fixedWidth: width - 32,
|
||||||
|
wordWrap: {
|
||||||
|
width: width - 32,
|
||||||
|
useAdvancedWrap: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
));
|
||||||
|
this.campaignJournalCardViews.push({
|
||||||
|
kind,
|
||||||
|
background,
|
||||||
|
status,
|
||||||
|
title,
|
||||||
|
detail
|
||||||
|
});
|
||||||
|
this.campaignJournalVisibleText.push(
|
||||||
|
cardStyle.status,
|
||||||
|
title.text,
|
||||||
|
detail.text
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private renderProgressStatCard(label: string, value: string, x: number, y: number, width: number) {
|
private renderProgressStatCard(label: string, value: string, x: number, y: number, width: number) {
|
||||||
@@ -25701,9 +25958,17 @@ export class CampScene extends Phaser.Scene {
|
|||||||
private showCampNotice(message: string) {
|
private showCampNotice(message: string) {
|
||||||
this.clearCampNotice();
|
this.clearCampNotice();
|
||||||
const depth = this.sortieObjects.length > 0 ? 72 : 30;
|
const depth = this.sortieObjects.length > 0 ? 72 : 30;
|
||||||
|
const journalOpen = this.activeTab === 'progress';
|
||||||
|
const noticeX = journalOpen ? 202 : campLegacyCanvasWidth / 2;
|
||||||
|
const noticeY = journalOpen ? 116 : 104;
|
||||||
|
const noticeMinimumWidth = journalOpen ? 300 : 420;
|
||||||
|
const noticeMaximumWidth = journalOpen ? 340 : 760;
|
||||||
const longestLineLength = Math.max(...message.split('\n').map((line) => line.length));
|
const longestLineLength = Math.max(...message.split('\n').map((line) => line.length));
|
||||||
const noticeWidth = Math.min(760, Math.max(420, longestLineLength * 13));
|
const noticeWidth = Math.min(
|
||||||
const text = this.scaleLegacyCampUi(this.add.text(campLegacyCanvasWidth / 2, 104, message, {
|
noticeMaximumWidth,
|
||||||
|
Math.max(noticeMinimumWidth, longestLineLength * 13)
|
||||||
|
);
|
||||||
|
const text = this.scaleLegacyCampUi(this.add.text(noticeX, noticeY, message, {
|
||||||
...this.textStyle(14, '#f2e3bf', true),
|
...this.textStyle(14, '#f2e3bf', true),
|
||||||
align: 'center',
|
align: 'center',
|
||||||
wordWrap: { width: noticeWidth - 44, useAdvancedWrap: true }
|
wordWrap: { width: noticeWidth - 44, useAdvancedWrap: true }
|
||||||
@@ -25712,7 +25977,9 @@ export class CampScene extends Phaser.Scene {
|
|||||||
text.setDepth(depth + 1);
|
text.setDepth(depth + 1);
|
||||||
text.setLineSpacing(5);
|
text.setLineSpacing(5);
|
||||||
const noticeHeight = Math.max(56, text.height + 28);
|
const noticeHeight = Math.max(56, text.height + 28);
|
||||||
const box = this.scaleLegacyCampUi(this.add.rectangle(campLegacyCanvasWidth / 2, 104, noticeWidth, noticeHeight, 0x101820, 0.96));
|
const box = this.scaleLegacyCampUi(
|
||||||
|
this.add.rectangle(noticeX, noticeY, noticeWidth, noticeHeight, 0x101820, 0.96)
|
||||||
|
);
|
||||||
box.setStrokeStyle(2, palette.gold, 0.84);
|
box.setStrokeStyle(2, palette.gold, 0.84);
|
||||||
box.setDepth(depth);
|
box.setDepth(depth);
|
||||||
const noticeObjects: Phaser.GameObjects.GameObject[] = [box, text];
|
const noticeObjects: Phaser.GameObjects.GameObject[] = [box, text];
|
||||||
@@ -26601,6 +26868,10 @@ export class CampScene extends Phaser.Scene {
|
|||||||
private clearContent() {
|
private clearContent() {
|
||||||
this.contentObjects.forEach((object) => object.destroy());
|
this.contentObjects.forEach((object) => object.destroy());
|
||||||
this.contentObjects = [];
|
this.contentObjects = [];
|
||||||
|
this.campaignJournalPanel = undefined;
|
||||||
|
this.campaignJournalCardViews = [];
|
||||||
|
this.campaignJournalVisibleText = [];
|
||||||
|
this.campaignJournalVisibleChapterTitles = [];
|
||||||
this.campDialogueRowButtons = {};
|
this.campDialogueRowButtons = {};
|
||||||
this.campDialogueChoiceButtons = {};
|
this.campDialogueChoiceButtons = {};
|
||||||
this.campDialogueBodyText = undefined;
|
this.campDialogueBodyText = undefined;
|
||||||
@@ -26675,6 +26946,41 @@ export class CampScene extends Phaser.Scene {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private campaignObjectiveJournalDebug() {
|
||||||
|
const trigger = this.tabButtons.find(
|
||||||
|
(button) => button.tab === 'progress'
|
||||||
|
);
|
||||||
|
const visible = this.activeTab === 'progress';
|
||||||
|
return {
|
||||||
|
visible,
|
||||||
|
shortcut: 'J',
|
||||||
|
triggerBounds: this.sortieObjectBoundsDebug(trigger?.bg),
|
||||||
|
buttonBounds: this.sortieObjectBoundsDebug(trigger?.bg),
|
||||||
|
panelBounds: visible
|
||||||
|
? this.sortieObjectBoundsDebug(this.campaignJournalPanel)
|
||||||
|
: null,
|
||||||
|
closeBounds: null,
|
||||||
|
objectCount: visible
|
||||||
|
? 1 + this.campaignJournalCardViews.length
|
||||||
|
: 0,
|
||||||
|
cards: visible
|
||||||
|
? this.campaignJournalCardViews.map((card) => ({
|
||||||
|
kind: card.kind === 'next-clue' ? 'nextClue' : card.kind,
|
||||||
|
label: card.status.text,
|
||||||
|
title: card.title.text,
|
||||||
|
body: card.detail.text,
|
||||||
|
bounds: this.sortieObjectBoundsDebug(card.background),
|
||||||
|
titleBounds: this.sortieTextBoundsDebug(card.title),
|
||||||
|
bodyBounds: this.sortieTextBoundsDebug(card.detail)
|
||||||
|
}))
|
||||||
|
: [],
|
||||||
|
visibleText: visible ? [...this.campaignJournalVisibleText] : [],
|
||||||
|
revealedChapterTitles: visible
|
||||||
|
? [...this.campaignJournalVisibleChapterTitles]
|
||||||
|
: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
getDebugState() {
|
getDebugState() {
|
||||||
const sortieChecklist = this.sortieChecklist();
|
const sortieChecklist = this.sortieChecklist();
|
||||||
const sortieScenario = this.nextSortieScenario();
|
const sortieScenario = this.nextSortieScenario();
|
||||||
@@ -28158,6 +28464,12 @@ export class CampScene extends Phaser.Scene {
|
|||||||
rosterCollection: this.rosterCollectionSummary(),
|
rosterCollection: this.rosterCollectionSummary(),
|
||||||
reserveTrainingAwards: this.latestReserveTrainingAwards(),
|
reserveTrainingAwards: this.latestReserveTrainingAwards(),
|
||||||
campaignProgress: this.campaignTimelineProgress(),
|
campaignProgress: this.campaignTimelineProgress(),
|
||||||
|
campaignObjectiveJournal: {
|
||||||
|
snapshot: resolveCampaignObjectiveJournal(
|
||||||
|
this.campaign ?? getCampaignState()
|
||||||
|
),
|
||||||
|
view: this.campaignObjectiveJournalDebug()
|
||||||
|
},
|
||||||
campBattleId: this.currentCampBattleId(),
|
campBattleId: this.currentCampBattleId(),
|
||||||
campTitle: this.currentCampTitle(),
|
campTitle: this.currentCampTitle(),
|
||||||
campSoundscape: this.campSoundscape
|
campSoundscape: this.campSoundscape
|
||||||
@@ -28234,6 +28546,14 @@ export class CampScene extends Phaser.Scene {
|
|||||||
sortieItemAssignments: this.campaign.sortieItemAssignments,
|
sortieItemAssignments: this.campaign.sortieItemAssignments,
|
||||||
sortieResonanceSelection: this.campaign.sortieResonanceSelection ?? null,
|
sortieResonanceSelection: this.campaign.sortieResonanceSelection ?? null,
|
||||||
activeSaveSlot: this.campaign.activeSaveSlot,
|
activeSaveSlot: this.campaign.activeSaveSlot,
|
||||||
|
acknowledgedVictoryRewardCategories: Object.fromEntries(
|
||||||
|
Object.entries(
|
||||||
|
this.campaign.acknowledgedVictoryRewardCategories
|
||||||
|
).map(([battleId, categories]) => [
|
||||||
|
battleId,
|
||||||
|
[...(categories ?? [])]
|
||||||
|
])
|
||||||
|
),
|
||||||
reserveTrainingAssignments: this.campaign.reserveTrainingAssignments,
|
reserveTrainingAssignments: this.campaign.reserveTrainingAssignments,
|
||||||
reserveTrainingFocus: this.campaign.reserveTrainingFocus,
|
reserveTrainingFocus: this.campaign.reserveTrainingFocus,
|
||||||
roster: this.campaign.roster.map((unit) => ({
|
roster: this.campaign.roster.map((unit) => ({
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ import { ExplorationInputController } from '../input/ExplorationInputController'
|
|||||||
import { releaseTextureSource } from '../render/loaderLifecycle';
|
import { releaseTextureSource } from '../render/loaderLifecycle';
|
||||||
import { isVisualMotionReduced } from '../settings/visualMotion';
|
import { isVisualMotionReduced } from '../settings/visualMotion';
|
||||||
import { getCampaignState } from '../state/campaignState';
|
import { getCampaignState } from '../state/campaignState';
|
||||||
|
import { resolveCampaignObjectiveJournal } from '../state/campaignObjectiveJournal';
|
||||||
import { completeFirstPursuitScoutVisit } from '../state/firstPursuitScoutActions';
|
import { completeFirstPursuitScoutVisit } from '../state/firstPursuitScoutActions';
|
||||||
import {
|
import {
|
||||||
completeSecondBattleReliefExploration,
|
completeSecondBattleReliefExploration,
|
||||||
@@ -62,6 +63,7 @@ import {
|
|||||||
recordThirdCampExplorationActivity,
|
recordThirdCampExplorationActivity,
|
||||||
thirdCampExplorationProgress
|
thirdCampExplorationProgress
|
||||||
} from '../state/thirdCampExplorationActions';
|
} from '../state/thirdCampExplorationActions';
|
||||||
|
import { CampaignObjectiveJournalOverlay } from '../ui/CampaignObjectiveJournalOverlay';
|
||||||
import { fitDialogueFacePortrait } from '../ui/dialoguePortrait';
|
import { fitDialogueFacePortrait } from '../ui/dialoguePortrait';
|
||||||
import { startGameScene } from './lazyScenes';
|
import { startGameScene } from './lazyScenes';
|
||||||
|
|
||||||
@@ -351,6 +353,7 @@ export class CampVisitExplorationScene extends Phaser.Scene {
|
|||||||
private lastNotice = '';
|
private lastNotice = '';
|
||||||
private ownedPresentationTextureKeys = new Set<string>();
|
private ownedPresentationTextureKeys = new Set<string>();
|
||||||
private visualMotionReduced = false;
|
private visualMotionReduced = false;
|
||||||
|
private campaignObjectiveJournal?: CampaignObjectiveJournalOverlay;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super('CampVisitExplorationScene');
|
super('CampVisitExplorationScene');
|
||||||
@@ -400,6 +403,8 @@ export class CampVisitExplorationScene extends Phaser.Scene {
|
|||||||
this.explorationInput?.destroy();
|
this.explorationInput?.destroy();
|
||||||
this.explorationInput = undefined;
|
this.explorationInput = undefined;
|
||||||
this.dialogueState = undefined;
|
this.dialogueState = undefined;
|
||||||
|
this.campaignObjectiveJournal?.destroy();
|
||||||
|
this.campaignObjectiveJournal = undefined;
|
||||||
this.closeChoicePanel();
|
this.closeChoicePanel();
|
||||||
releaseExplorationCharacterTextureKeys(
|
releaseExplorationCharacterTextureKeys(
|
||||||
this,
|
this,
|
||||||
@@ -445,6 +450,7 @@ export class CampVisitExplorationScene extends Phaser.Scene {
|
|||||||
this.createDialoguePanel();
|
this.createDialoguePanel();
|
||||||
this.ready = true;
|
this.ready = true;
|
||||||
this.inputReadyAt = this.time.now + inputCarryoverGuardMs;
|
this.inputReadyAt = this.time.now + inputCarryoverGuardMs;
|
||||||
|
this.createCampaignObjectiveJournal();
|
||||||
this.refreshObjectiveHud();
|
this.refreshObjectiveHud();
|
||||||
this.refreshActorMarkers();
|
this.refreshActorMarkers();
|
||||||
this.refreshInteractionPrompt();
|
this.refreshInteractionPrompt();
|
||||||
@@ -493,6 +499,11 @@ export class CampVisitExplorationScene extends Phaser.Scene {
|
|||||||
this.explorationInput?.discardInteractionRequest();
|
this.explorationInput?.discardInteractionRequest();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (this.campaignObjectiveJournal?.isOpen()) {
|
||||||
|
this.stopPlayerMovement();
|
||||||
|
this.explorationInput?.discardInteractionRequest();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (this.choicePanel) {
|
if (this.choicePanel) {
|
||||||
this.stopPlayerMovement();
|
this.stopPlayerMovement();
|
||||||
this.explorationInput?.discardInteractionRequest();
|
this.explorationInput?.discardInteractionRequest();
|
||||||
@@ -560,6 +571,10 @@ export class CampVisitExplorationScene extends Phaser.Scene {
|
|||||||
ready: this.ready,
|
ready: this.ready,
|
||||||
viewport: { width: this.scale.width, height: this.scale.height },
|
viewport: { width: this.scale.width, height: this.scale.height },
|
||||||
campaignStep: campaign.step,
|
campaignStep: campaign.step,
|
||||||
|
campaignObjectiveJournal: {
|
||||||
|
snapshot: resolveCampaignObjectiveJournal(campaign),
|
||||||
|
view: this.campaignObjectiveJournal?.getDebugState() ?? null
|
||||||
|
},
|
||||||
background: {
|
background: {
|
||||||
key: this.backgroundImage?.texture.key ?? this.currentBackgroundKey(),
|
key: this.backgroundImage?.texture.key ?? this.currentBackgroundKey(),
|
||||||
ready: this.backgroundImage?.active === true,
|
ready: this.backgroundImage?.active === true,
|
||||||
@@ -1119,6 +1134,7 @@ export class CampVisitExplorationScene extends Phaser.Scene {
|
|||||||
this.actorViews.clear();
|
this.actorViews.clear();
|
||||||
this.blockers = [];
|
this.blockers = [];
|
||||||
this.dialogueState = undefined;
|
this.dialogueState = undefined;
|
||||||
|
this.campaignObjectiveJournal = undefined;
|
||||||
this.choicePanel = undefined;
|
this.choicePanel = undefined;
|
||||||
this.choicePanelBounds = undefined;
|
this.choicePanelBounds = undefined;
|
||||||
this.choiceViews = [];
|
this.choiceViews = [];
|
||||||
@@ -2291,7 +2307,16 @@ export class CampVisitExplorationScene extends Phaser.Scene {
|
|||||||
.bindKey(
|
.bindKey(
|
||||||
Phaser.Input.Keyboard.KeyCodes.TWO,
|
Phaser.Input.Keyboard.KeyCodes.TWO,
|
||||||
() => this.chooseChoiceByIndex(1)
|
() => this.chooseChoiceByIndex(1)
|
||||||
|
)
|
||||||
|
.bindKey(
|
||||||
|
Phaser.Input.Keyboard.KeyCodes.J,
|
||||||
|
() => this.campaignObjectiveJournal?.toggle()
|
||||||
);
|
);
|
||||||
|
this.input.keyboard?.on('keydown-ESC', () => {
|
||||||
|
if (this.campaignObjectiveJournal?.isOpen()) {
|
||||||
|
this.campaignObjectiveJournal.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
this.input.on(
|
this.input.on(
|
||||||
Phaser.Input.Events.POINTER_DOWN,
|
Phaser.Input.Events.POINTER_DOWN,
|
||||||
@@ -2299,6 +2324,7 @@ export class CampVisitExplorationScene extends Phaser.Scene {
|
|||||||
if (
|
if (
|
||||||
!this.ready ||
|
!this.ready ||
|
||||||
this.navigationPending ||
|
this.navigationPending ||
|
||||||
|
this.campaignObjectiveJournal?.isOpen() ||
|
||||||
this.time.now < this.inputReadyAt
|
this.time.now < this.inputReadyAt
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
@@ -2334,6 +2360,10 @@ export class CampVisitExplorationScene extends Phaser.Scene {
|
|||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (this.campaignObjectiveJournal?.isOpen()) {
|
||||||
|
this.campaignObjectiveJournal.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (this.choicePanel) {
|
if (this.choicePanel) {
|
||||||
this.closeChoicePanel();
|
this.closeChoicePanel();
|
||||||
return;
|
return;
|
||||||
@@ -2351,6 +2381,16 @@ export class CampVisitExplorationScene extends Phaser.Scene {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private createCampaignObjectiveJournal() {
|
||||||
|
this.campaignObjectiveJournal = new CampaignObjectiveJournalOverlay(this, {
|
||||||
|
snapshotProvider: () =>
|
||||||
|
resolveCampaignObjectiveJournal(getCampaignState()),
|
||||||
|
canOpen: () => this.ready && !this.navigationPending,
|
||||||
|
buttonX: 1215,
|
||||||
|
buttonY: 21
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private updateMovement(time: number, delta: number) {
|
private updateMovement(time: number, delta: number) {
|
||||||
if (!this.player) {
|
if (!this.player) {
|
||||||
return;
|
return;
|
||||||
@@ -3466,6 +3506,7 @@ export class CampVisitExplorationScene extends Phaser.Scene {
|
|||||||
if (
|
if (
|
||||||
!this.choicePanel ||
|
!this.choicePanel ||
|
||||||
this.navigationPending ||
|
this.navigationPending ||
|
||||||
|
this.campaignObjectiveJournal?.isOpen() ||
|
||||||
this.time.now < this.choiceReadyAt
|
this.time.now < this.choiceReadyAt
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
@@ -3477,6 +3518,13 @@ export class CampVisitExplorationScene extends Phaser.Scene {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private chooseVisitChoice(choice: ChoiceOption) {
|
private chooseVisitChoice(choice: ChoiceOption) {
|
||||||
|
if (
|
||||||
|
!this.choicePanel ||
|
||||||
|
this.navigationPending ||
|
||||||
|
this.campaignObjectiveJournal?.isOpen()
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (this.isThirdCampVisit()) {
|
if (this.isThirdCampVisit()) {
|
||||||
if ('rewardExp' in choice) {
|
if ('rewardExp' in choice) {
|
||||||
this.chooseThirdCampCompanion(choice);
|
this.chooseThirdCampCompanion(choice);
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ import {
|
|||||||
setActiveCityStayId,
|
setActiveCityStayId,
|
||||||
type CampaignState
|
type CampaignState
|
||||||
} from '../state/campaignState';
|
} from '../state/campaignState';
|
||||||
|
import { resolveCampaignObjectiveJournal } from '../state/campaignObjectiveJournal';
|
||||||
|
import { CampaignObjectiveJournalOverlay } from '../ui/CampaignObjectiveJournalOverlay';
|
||||||
import { fitDialogueFacePortrait } from '../ui/dialoguePortrait';
|
import { fitDialogueFacePortrait } from '../ui/dialoguePortrait';
|
||||||
import { startGameScene } from './lazyScenes';
|
import { startGameScene } from './lazyScenes';
|
||||||
|
|
||||||
@@ -209,6 +211,7 @@ export class CityStayScene extends Phaser.Scene {
|
|||||||
private lastNotice = '';
|
private lastNotice = '';
|
||||||
private visualMotionReduced = false;
|
private visualMotionReduced = false;
|
||||||
private ownedPresentationTextureKeys = new Set<string>();
|
private ownedPresentationTextureKeys = new Set<string>();
|
||||||
|
private campaignObjectiveJournal?: CampaignObjectiveJournalOverlay;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super('CityStayScene');
|
super('CityStayScene');
|
||||||
@@ -267,6 +270,7 @@ export class CityStayScene extends Phaser.Scene {
|
|||||||
this.createDialoguePanel();
|
this.createDialoguePanel();
|
||||||
this.ready = true;
|
this.ready = true;
|
||||||
this.inputReadyAt = this.time.now + inputCarryoverGuardMs;
|
this.inputReadyAt = this.time.now + inputCarryoverGuardMs;
|
||||||
|
this.createCampaignObjectiveJournal();
|
||||||
this.refreshProgressHud();
|
this.refreshProgressHud();
|
||||||
this.refreshActorMarkers();
|
this.refreshActorMarkers();
|
||||||
this.refreshInteractionPrompt();
|
this.refreshInteractionPrompt();
|
||||||
@@ -285,6 +289,8 @@ export class CityStayScene extends Phaser.Scene {
|
|||||||
this.explorationInput?.destroy();
|
this.explorationInput?.destroy();
|
||||||
this.explorationInput = undefined;
|
this.explorationInput = undefined;
|
||||||
this.dialogueState = undefined;
|
this.dialogueState = undefined;
|
||||||
|
this.campaignObjectiveJournal?.destroy();
|
||||||
|
this.campaignObjectiveJournal = undefined;
|
||||||
releaseExplorationCharacterTextureKeys(
|
releaseExplorationCharacterTextureKeys(
|
||||||
this,
|
this,
|
||||||
this.characterTextureKeys()
|
this.characterTextureKeys()
|
||||||
@@ -299,6 +305,12 @@ export class CityStayScene extends Phaser.Scene {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.campaignObjectiveJournal?.isOpen()) {
|
||||||
|
this.stopPlayerMovement();
|
||||||
|
this.explorationInput?.discardInteractionRequest();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (this.shopPanel || this.choicePanel) {
|
if (this.shopPanel || this.choicePanel) {
|
||||||
this.stopPlayerMovement();
|
this.stopPlayerMovement();
|
||||||
this.explorationInput?.discardInteractionRequest();
|
this.explorationInput?.discardInteractionRequest();
|
||||||
@@ -339,6 +351,10 @@ export class CityStayScene extends Phaser.Scene {
|
|||||||
ready: this.ready,
|
ready: this.ready,
|
||||||
cityStayId: this.cityStay.id,
|
cityStayId: this.cityStay.id,
|
||||||
cityName: this.cityStay.city.name,
|
cityName: this.cityStay.city.name,
|
||||||
|
campaignObjectiveJournal: {
|
||||||
|
snapshot: resolveCampaignObjectiveJournal(campaign),
|
||||||
|
view: this.campaignObjectiveJournal?.getDebugState() ?? null
|
||||||
|
},
|
||||||
visualMotionReduced: this.visualMotionReduced,
|
visualMotionReduced: this.visualMotionReduced,
|
||||||
viewport: { width: this.scale.width, height: this.scale.height },
|
viewport: { width: this.scale.width, height: this.scale.height },
|
||||||
activeCityStayId: campaign.activeCityStayId ?? null,
|
activeCityStayId: campaign.activeCityStayId ?? null,
|
||||||
@@ -581,6 +597,7 @@ export class CityStayScene extends Phaser.Scene {
|
|||||||
this.dialoguePortraitFrame = undefined;
|
this.dialoguePortraitFrame = undefined;
|
||||||
this.dialoguePortrait = undefined;
|
this.dialoguePortrait = undefined;
|
||||||
this.dialogueState = undefined;
|
this.dialogueState = undefined;
|
||||||
|
this.campaignObjectiveJournal = undefined;
|
||||||
this.shopPanel = undefined;
|
this.shopPanel = undefined;
|
||||||
this.shopOfferViews = [];
|
this.shopOfferViews = [];
|
||||||
this.shopNotice = '';
|
this.shopNotice = '';
|
||||||
@@ -1298,10 +1315,24 @@ export class CityStayScene extends Phaser.Scene {
|
|||||||
.bindKey(
|
.bindKey(
|
||||||
Phaser.Input.Keyboard.KeyCodes.TWO,
|
Phaser.Input.Keyboard.KeyCodes.TWO,
|
||||||
() => this.chooseDialogueByIndex(1)
|
() => this.chooseDialogueByIndex(1)
|
||||||
|
)
|
||||||
|
.bindKey(
|
||||||
|
Phaser.Input.Keyboard.KeyCodes.J,
|
||||||
|
() => this.campaignObjectiveJournal?.toggle()
|
||||||
);
|
);
|
||||||
|
this.input.keyboard?.on('keydown-ESC', () => {
|
||||||
|
if (this.campaignObjectiveJournal?.isOpen()) {
|
||||||
|
this.campaignObjectiveJournal.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
this.input.on(Phaser.Input.Events.POINTER_DOWN, (pointer: Phaser.Input.Pointer) => {
|
this.input.on(Phaser.Input.Events.POINTER_DOWN, (pointer: Phaser.Input.Pointer) => {
|
||||||
if (!this.ready || this.navigationPending || this.time.now < this.inputReadyAt) {
|
if (
|
||||||
|
!this.ready ||
|
||||||
|
this.navigationPending ||
|
||||||
|
this.campaignObjectiveJournal?.isOpen() ||
|
||||||
|
this.time.now < this.inputReadyAt
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (this.shopPanel || this.choicePanel) {
|
if (this.shopPanel || this.choicePanel) {
|
||||||
@@ -1332,6 +1363,10 @@ export class CityStayScene extends Phaser.Scene {
|
|||||||
if (!this.ready || this.navigationPending || this.time.now < this.inputReadyAt) {
|
if (!this.ready || this.navigationPending || this.time.now < this.inputReadyAt) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (this.campaignObjectiveJournal?.isOpen()) {
|
||||||
|
this.campaignObjectiveJournal.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (this.shopPanel) {
|
if (this.shopPanel) {
|
||||||
this.closeShop();
|
this.closeShop();
|
||||||
return;
|
return;
|
||||||
@@ -2135,7 +2170,11 @@ export class CityStayScene extends Phaser.Scene {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private chooseDialogueByIndex(index: number) {
|
private chooseDialogueByIndex(index: number) {
|
||||||
if (!this.choicePanel || this.navigationPending) {
|
if (
|
||||||
|
!this.choicePanel ||
|
||||||
|
this.navigationPending ||
|
||||||
|
this.campaignObjectiveJournal?.isOpen()
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const choice = this.cityStay.dialogue.choices[index];
|
const choice = this.cityStay.dialogue.choices[index];
|
||||||
@@ -2145,6 +2184,13 @@ export class CityStayScene extends Phaser.Scene {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private chooseDialogue(choice: CityResonanceDialogueChoice) {
|
private chooseDialogue(choice: CityResonanceDialogueChoice) {
|
||||||
|
if (
|
||||||
|
!this.choicePanel ||
|
||||||
|
this.navigationPending ||
|
||||||
|
this.campaignObjectiveJournal?.isOpen()
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const result = chooseCityStayResonance(this.cityStay.id, choice.id);
|
const result = chooseCityStayResonance(this.cityStay.id, choice.id);
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
this.closeChoicePanel();
|
this.closeChoicePanel();
|
||||||
@@ -2905,7 +2951,22 @@ export class CityStayScene extends Phaser.Scene {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private modalOpen() {
|
private modalOpen() {
|
||||||
return Boolean(this.dialogueState || this.shopPanel || this.choicePanel);
|
return Boolean(
|
||||||
|
this.campaignObjectiveJournal?.isOpen() ||
|
||||||
|
this.dialogueState ||
|
||||||
|
this.shopPanel ||
|
||||||
|
this.choicePanel
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private createCampaignObjectiveJournal() {
|
||||||
|
this.campaignObjectiveJournal = new CampaignObjectiveJournalOverlay(this, {
|
||||||
|
snapshotProvider: () =>
|
||||||
|
resolveCampaignObjectiveJournal(getCampaignState()),
|
||||||
|
canOpen: () => this.ready && !this.navigationPending,
|
||||||
|
buttonX: 1215,
|
||||||
|
buttonY: 21
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private releasePresentationTextures() {
|
private releasePresentationTextures() {
|
||||||
|
|||||||
@@ -41,8 +41,10 @@ import {
|
|||||||
setCampaignSortieOrderSelection,
|
setCampaignSortieOrderSelection,
|
||||||
type CampaignTutorialId
|
type CampaignTutorialId
|
||||||
} from '../state/campaignState';
|
} from '../state/campaignState';
|
||||||
|
import { resolveCampaignObjectiveJournal } from '../state/campaignObjectiveJournal';
|
||||||
import { releaseTextureSource } from '../render/loaderLifecycle';
|
import { releaseTextureSource } from '../render/loaderLifecycle';
|
||||||
import { isVisualMotionReduced } from '../settings/visualMotion';
|
import { isVisualMotionReduced } from '../settings/visualMotion';
|
||||||
|
import { CampaignObjectiveJournalOverlay } from '../ui/CampaignObjectiveJournalOverlay';
|
||||||
import { fitDialogueFacePortrait } from '../ui/dialoguePortrait';
|
import { fitDialogueFacePortrait } from '../ui/dialoguePortrait';
|
||||||
import { palette } from '../ui/palette';
|
import { palette } from '../ui/palette';
|
||||||
import { startGameScene } from './lazyScenes';
|
import { startGameScene } from './lazyScenes';
|
||||||
@@ -228,6 +230,7 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
|
|||||||
private firstVisit = false;
|
private firstVisit = false;
|
||||||
private visualMotionReduced = false;
|
private visualMotionReduced = false;
|
||||||
private ownedPresentationTextureKeys = new Set<string>();
|
private ownedPresentationTextureKeys = new Set<string>();
|
||||||
|
private campaignObjectiveJournal?: CampaignObjectiveJournalOverlay;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super('PrologueMilitiaCampScene');
|
super('PrologueMilitiaCampScene');
|
||||||
@@ -276,6 +279,8 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
|
|||||||
this.navigationPending = false;
|
this.navigationPending = false;
|
||||||
this.clearNavigationTarget(true);
|
this.clearNavigationTarget(true);
|
||||||
this.dialogueState = undefined;
|
this.dialogueState = undefined;
|
||||||
|
this.campaignObjectiveJournal?.destroy();
|
||||||
|
this.campaignObjectiveJournal = undefined;
|
||||||
this.closeCommandChoice();
|
this.closeCommandChoice();
|
||||||
this.stopNpcMovement();
|
this.stopNpcMovement();
|
||||||
releaseExplorationCharacterTextureKeys(this, characterTextureKeys);
|
releaseExplorationCharacterTextureKeys(this, characterTextureKeys);
|
||||||
@@ -290,6 +295,7 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
|
|||||||
this.loadingOverlay = undefined;
|
this.loadingOverlay = undefined;
|
||||||
this.ready = true;
|
this.ready = true;
|
||||||
this.inputReadyAt = this.time.now + inputCarryoverGuardMs;
|
this.inputReadyAt = this.time.now + inputCarryoverGuardMs;
|
||||||
|
this.createCampaignObjectiveJournal();
|
||||||
this.refreshObjectiveHud();
|
this.refreshObjectiveHud();
|
||||||
this.refreshNpcMarkers();
|
this.refreshNpcMarkers();
|
||||||
this.refreshInteractionPrompt();
|
this.refreshInteractionPrompt();
|
||||||
@@ -313,6 +319,11 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
|
|||||||
this.interactionQueued = false;
|
this.interactionQueued = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (this.campaignObjectiveJournal?.isOpen()) {
|
||||||
|
this.stopPlayerMovement();
|
||||||
|
this.interactionQueued = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (this.commandChoicePanel) {
|
if (this.commandChoicePanel) {
|
||||||
this.stopPlayerMovement();
|
this.stopPlayerMovement();
|
||||||
this.interactionQueued = false;
|
this.interactionQueued = false;
|
||||||
@@ -349,6 +360,10 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
|
|||||||
scene: this.scene.key,
|
scene: this.scene.key,
|
||||||
locationId: 'zhuo-volunteer-camp',
|
locationId: 'zhuo-volunteer-camp',
|
||||||
ready: this.ready,
|
ready: this.ready,
|
||||||
|
campaignObjectiveJournal: {
|
||||||
|
snapshot: resolveCampaignObjectiveJournal(campaign),
|
||||||
|
view: this.campaignObjectiveJournal?.getDebugState() ?? null
|
||||||
|
},
|
||||||
visualMotionReduced: this.visualMotionReduced,
|
visualMotionReduced: this.visualMotionReduced,
|
||||||
viewport: { width: this.scale.width, height: this.scale.height },
|
viewport: { width: this.scale.width, height: this.scale.height },
|
||||||
campaignStep: campaign.step,
|
campaignStep: campaign.step,
|
||||||
@@ -622,6 +637,7 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
|
|||||||
this.dialoguePortraitFrame = undefined;
|
this.dialoguePortraitFrame = undefined;
|
||||||
this.dialoguePortrait = undefined;
|
this.dialoguePortrait = undefined;
|
||||||
this.dialogueState = undefined;
|
this.dialogueState = undefined;
|
||||||
|
this.campaignObjectiveJournal = undefined;
|
||||||
this.commandChoicePanel = undefined;
|
this.commandChoicePanel = undefined;
|
||||||
this.commandChoicePanelBounds = undefined;
|
this.commandChoicePanelBounds = undefined;
|
||||||
this.commandChoiceViews = [];
|
this.commandChoiceViews = [];
|
||||||
@@ -1399,7 +1415,15 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
|
|||||||
keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.ENTER)
|
keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.ENTER)
|
||||||
.on('down', () => this.confirmFirstCommand());
|
.on('down', () => this.confirmFirstCommand());
|
||||||
keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.ESC)
|
keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.ESC)
|
||||||
.on('down', () => this.cancelFirstCommandChoice());
|
.on('down', () => {
|
||||||
|
if (this.campaignObjectiveJournal?.isOpen()) {
|
||||||
|
this.campaignObjectiveJournal.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.cancelFirstCommandChoice();
|
||||||
|
});
|
||||||
|
keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.J)
|
||||||
|
.on('down', () => this.campaignObjectiveJournal?.toggle());
|
||||||
}
|
}
|
||||||
|
|
||||||
this.input.on(Phaser.Input.Events.POINTER_DOWN, (pointer: Phaser.Input.Pointer) => {
|
this.input.on(Phaser.Input.Events.POINTER_DOWN, (pointer: Phaser.Input.Pointer) => {
|
||||||
@@ -1407,6 +1431,7 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
|
|||||||
!this.ready ||
|
!this.ready ||
|
||||||
this.navigationPending ||
|
this.navigationPending ||
|
||||||
this.npcMovementPending ||
|
this.npcMovementPending ||
|
||||||
|
this.campaignObjectiveJournal?.isOpen() ||
|
||||||
this.time.now < this.inputReadyAt
|
this.time.now < this.inputReadyAt
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
@@ -1432,6 +1457,16 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private createCampaignObjectiveJournal() {
|
||||||
|
this.campaignObjectiveJournal = new CampaignObjectiveJournalOverlay(this, {
|
||||||
|
snapshotProvider: () =>
|
||||||
|
resolveCampaignObjectiveJournal(getCampaignState()),
|
||||||
|
canOpen: () => this.ready && !this.navigationPending,
|
||||||
|
buttonX: 1215,
|
||||||
|
buttonY: 21
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private updateMovement(time: number, delta: number) {
|
private updateMovement(time: number, delta: number) {
|
||||||
if (!this.player) {
|
if (!this.player) {
|
||||||
return;
|
return;
|
||||||
@@ -2395,6 +2430,7 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
|
|||||||
if (
|
if (
|
||||||
!this.commandChoicePanel ||
|
!this.commandChoicePanel ||
|
||||||
this.navigationPending ||
|
this.navigationPending ||
|
||||||
|
this.campaignObjectiveJournal?.isOpen() ||
|
||||||
(!bypassInputGuard && this.time.now < this.commandChoiceReadyAt)
|
(!bypassInputGuard && this.time.now < this.commandChoiceReadyAt)
|
||||||
) {
|
) {
|
||||||
return false;
|
return false;
|
||||||
@@ -2434,6 +2470,7 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
|
|||||||
!this.commandChoicePanel ||
|
!this.commandChoicePanel ||
|
||||||
!this.pendingCommandOrderId ||
|
!this.pendingCommandOrderId ||
|
||||||
this.navigationPending ||
|
this.navigationPending ||
|
||||||
|
this.campaignObjectiveJournal?.isOpen() ||
|
||||||
(!bypassInputGuard && this.time.now < this.commandChoiceReadyAt)
|
(!bypassInputGuard && this.time.now < this.commandChoiceReadyAt)
|
||||||
) {
|
) {
|
||||||
return false;
|
return false;
|
||||||
@@ -2461,6 +2498,7 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
|
|||||||
if (
|
if (
|
||||||
!this.commandChoicePanel ||
|
!this.commandChoicePanel ||
|
||||||
this.navigationPending ||
|
this.navigationPending ||
|
||||||
|
this.campaignObjectiveJournal?.isOpen() ||
|
||||||
this.time.now < this.commandChoiceReadyAt
|
this.time.now < this.commandChoiceReadyAt
|
||||||
) {
|
) {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -35,8 +35,10 @@ import {
|
|||||||
prologueVillageCampaignTutorialIds,
|
prologueVillageCampaignTutorialIds,
|
||||||
type CampaignTutorialId
|
type CampaignTutorialId
|
||||||
} from '../state/campaignState';
|
} from '../state/campaignState';
|
||||||
|
import { resolveCampaignObjectiveJournal } from '../state/campaignObjectiveJournal';
|
||||||
import { releaseTextureSource } from '../render/loaderLifecycle';
|
import { releaseTextureSource } from '../render/loaderLifecycle';
|
||||||
import { isVisualMotionReduced } from '../settings/visualMotion';
|
import { isVisualMotionReduced } from '../settings/visualMotion';
|
||||||
|
import { CampaignObjectiveJournalOverlay } from '../ui/CampaignObjectiveJournalOverlay';
|
||||||
import { fitDialogueFacePortrait } from '../ui/dialoguePortrait';
|
import { fitDialogueFacePortrait } from '../ui/dialoguePortrait';
|
||||||
import { palette } from '../ui/palette';
|
import { palette } from '../ui/palette';
|
||||||
import { startGameScene } from './lazyScenes';
|
import { startGameScene } from './lazyScenes';
|
||||||
@@ -247,6 +249,7 @@ export class PrologueVillageScene extends Phaser.Scene {
|
|||||||
private firstVisit = false;
|
private firstVisit = false;
|
||||||
private visualMotionReduced = false;
|
private visualMotionReduced = false;
|
||||||
private ownedPresentationTextureKeys = new Set<string>();
|
private ownedPresentationTextureKeys = new Set<string>();
|
||||||
|
private campaignObjectiveJournal?: CampaignObjectiveJournalOverlay;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super('PrologueVillageScene');
|
super('PrologueVillageScene');
|
||||||
@@ -297,6 +300,8 @@ export class PrologueVillageScene extends Phaser.Scene {
|
|||||||
this.clearNavigationTarget(true);
|
this.clearNavigationTarget(true);
|
||||||
this.queuedMovementIntent = undefined;
|
this.queuedMovementIntent = undefined;
|
||||||
this.dialogueState = undefined;
|
this.dialogueState = undefined;
|
||||||
|
this.campaignObjectiveJournal?.destroy();
|
||||||
|
this.campaignObjectiveJournal = undefined;
|
||||||
this.stopAllNpcMovement();
|
this.stopAllNpcMovement();
|
||||||
releaseExplorationCharacterTextureKeys(this, characterTextureKeys);
|
releaseExplorationCharacterTextureKeys(this, characterTextureKeys);
|
||||||
this.releasePresentationTextures();
|
this.releasePresentationTextures();
|
||||||
@@ -310,6 +315,7 @@ export class PrologueVillageScene extends Phaser.Scene {
|
|||||||
this.loadingOverlay = undefined;
|
this.loadingOverlay = undefined;
|
||||||
this.ready = true;
|
this.ready = true;
|
||||||
this.inputReadyAt = this.time.now + inputCarryoverGuardMs;
|
this.inputReadyAt = this.time.now + inputCarryoverGuardMs;
|
||||||
|
this.createCampaignObjectiveJournal();
|
||||||
this.refreshObjectiveHud();
|
this.refreshObjectiveHud();
|
||||||
this.refreshNpcMarkers();
|
this.refreshNpcMarkers();
|
||||||
this.refreshInteractionPrompt();
|
this.refreshInteractionPrompt();
|
||||||
@@ -331,6 +337,12 @@ export class PrologueVillageScene extends Phaser.Scene {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.campaignObjectiveJournal?.isOpen()) {
|
||||||
|
this.interactionQueued = false;
|
||||||
|
this.stopPlayerMovement();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (this.oathGatherPending) {
|
if (this.oathGatherPending) {
|
||||||
this.interactionQueued = false;
|
this.interactionQueued = false;
|
||||||
this.stopPlayerMovement();
|
this.stopPlayerMovement();
|
||||||
@@ -377,6 +389,10 @@ export class PrologueVillageScene extends Phaser.Scene {
|
|||||||
visualMotionReduced: this.visualMotionReduced,
|
visualMotionReduced: this.visualMotionReduced,
|
||||||
viewport: { width: this.scale.width, height: this.scale.height },
|
viewport: { width: this.scale.width, height: this.scale.height },
|
||||||
campaignStep: campaign.step,
|
campaignStep: campaign.step,
|
||||||
|
campaignObjectiveJournal: {
|
||||||
|
snapshot: resolveCampaignObjectiveJournal(campaign),
|
||||||
|
view: this.campaignObjectiveJournal?.getDebugState() ?? null
|
||||||
|
},
|
||||||
background: {
|
background: {
|
||||||
key: this.backgroundImage?.texture.key ?? villageBackgroundKey,
|
key: this.backgroundImage?.texture.key ?? villageBackgroundKey,
|
||||||
ready: this.backgroundImage?.active === true,
|
ready: this.backgroundImage?.active === true,
|
||||||
@@ -577,6 +593,7 @@ export class PrologueVillageScene extends Phaser.Scene {
|
|||||||
this.dialoguePortraitFrame = undefined;
|
this.dialoguePortraitFrame = undefined;
|
||||||
this.dialoguePortrait = undefined;
|
this.dialoguePortrait = undefined;
|
||||||
this.dialogueState = undefined;
|
this.dialogueState = undefined;
|
||||||
|
this.campaignObjectiveJournal = undefined;
|
||||||
this.moveTarget = undefined;
|
this.moveTarget = undefined;
|
||||||
this.moveWaypoints = [];
|
this.moveWaypoints = [];
|
||||||
this.moveTargetNpcId = undefined;
|
this.moveTargetNpcId = undefined;
|
||||||
@@ -1187,6 +1204,14 @@ export class PrologueVillageScene extends Phaser.Scene {
|
|||||||
this.interactionQueued = true;
|
this.interactionQueued = true;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.J)
|
||||||
|
.on('down', () => this.campaignObjectiveJournal?.toggle());
|
||||||
|
keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.ESC)
|
||||||
|
.on('down', () => {
|
||||||
|
if (this.campaignObjectiveJournal?.isOpen()) {
|
||||||
|
this.campaignObjectiveJournal.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
keyboard.addCapture([
|
keyboard.addCapture([
|
||||||
Phaser.Input.Keyboard.KeyCodes.UP,
|
Phaser.Input.Keyboard.KeyCodes.UP,
|
||||||
Phaser.Input.Keyboard.KeyCodes.DOWN,
|
Phaser.Input.Keyboard.KeyCodes.DOWN,
|
||||||
@@ -1201,6 +1226,7 @@ export class PrologueVillageScene extends Phaser.Scene {
|
|||||||
!this.ready ||
|
!this.ready ||
|
||||||
this.navigationPending ||
|
this.navigationPending ||
|
||||||
this.oathGatherPending ||
|
this.oathGatherPending ||
|
||||||
|
this.campaignObjectiveJournal?.isOpen() ||
|
||||||
this.time.now < this.inputReadyAt
|
this.time.now < this.inputReadyAt
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
@@ -1228,6 +1254,16 @@ export class PrologueVillageScene extends Phaser.Scene {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private createCampaignObjectiveJournal() {
|
||||||
|
this.campaignObjectiveJournal = new CampaignObjectiveJournalOverlay(this, {
|
||||||
|
snapshotProvider: () =>
|
||||||
|
resolveCampaignObjectiveJournal(getCampaignState()),
|
||||||
|
canOpen: () => this.ready && !this.navigationPending,
|
||||||
|
buttonX: 1215,
|
||||||
|
buttonY: 21
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private updateMovement(time: number, delta: number) {
|
private updateMovement(time: number, delta: number) {
|
||||||
if (!this.player) {
|
if (!this.player) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -57,8 +57,10 @@ import {
|
|||||||
import { prologueOpeningPages } from '../data/prologueVillage';
|
import { prologueOpeningPages } from '../data/prologueVillage';
|
||||||
import { type PortraitKey, type StoryPage } from '../data/scenario';
|
import { type PortraitKey, type StoryPage } from '../data/scenario';
|
||||||
import { getCampaignState, getFirstBattleReport } from '../state/campaignState';
|
import { getCampaignState, getFirstBattleReport } from '../state/campaignState';
|
||||||
|
import { resolveCampaignObjectiveJournal } from '../state/campaignObjectiveJournal';
|
||||||
import { releaseTextureSource } from '../render/loaderLifecycle';
|
import { releaseTextureSource } from '../render/loaderLifecycle';
|
||||||
import { isVisualMotionReduced } from '../settings/visualMotion';
|
import { isVisualMotionReduced } from '../settings/visualMotion';
|
||||||
|
import { CampaignObjectiveJournalOverlay } from '../ui/CampaignObjectiveJournalOverlay';
|
||||||
import { fitDialogueFacePortrait } from '../ui/dialoguePortrait';
|
import { fitDialogueFacePortrait } from '../ui/dialoguePortrait';
|
||||||
import { palette } from '../ui/palette';
|
import { palette } from '../ui/palette';
|
||||||
import { ensureLazyScene, startGameScene } from './lazyScenes';
|
import { ensureLazyScene, startGameScene } from './lazyScenes';
|
||||||
@@ -236,6 +238,7 @@ export class StoryScene extends Phaser.Scene {
|
|||||||
private storyEnvironmentHaze: StoryEnvironmentHazeView[] = [];
|
private storyEnvironmentHaze: StoryEnvironmentHazeView[] = [];
|
||||||
private storyEnvironmentRandomState = 1;
|
private storyEnvironmentRandomState = 1;
|
||||||
private storyEnvironmentRevision = 0;
|
private storyEnvironmentRevision = 0;
|
||||||
|
private campaignObjectiveJournal?: CampaignObjectiveJournalOverlay;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super('StoryScene');
|
super('StoryScene');
|
||||||
@@ -290,6 +293,7 @@ export class StoryScene extends Phaser.Scene {
|
|||||||
this.storyEnvironmentHaze = [];
|
this.storyEnvironmentHaze = [];
|
||||||
this.storyEnvironmentRandomState = 1;
|
this.storyEnvironmentRandomState = 1;
|
||||||
this.storyEnvironmentRevision = 0;
|
this.storyEnvironmentRevision = 0;
|
||||||
|
this.campaignObjectiveJournal = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
getDebugState() {
|
getDebugState() {
|
||||||
@@ -309,6 +313,10 @@ export class StoryScene extends Phaser.Scene {
|
|||||||
return {
|
return {
|
||||||
scene: this.scene.key,
|
scene: this.scene.key,
|
||||||
ready: this.sys.isActive() && this.loadingText === undefined && this.background?.active === true,
|
ready: this.sys.isActive() && this.loadingText === undefined && this.background?.active === true,
|
||||||
|
campaignObjectiveJournal: {
|
||||||
|
snapshot: resolveCampaignObjectiveJournal(getCampaignState()),
|
||||||
|
view: this.campaignObjectiveJournal?.getDebugState() ?? null
|
||||||
|
},
|
||||||
pageIndex: this.pageIndex,
|
pageIndex: this.pageIndex,
|
||||||
totalPages: this.pages.length,
|
totalPages: this.pages.length,
|
||||||
currentPageId: page?.id,
|
currentPageId: page?.id,
|
||||||
@@ -426,6 +434,8 @@ export class StoryScene extends Phaser.Scene {
|
|||||||
this.activeStoryLoadErrorHandler = undefined;
|
this.activeStoryLoadErrorHandler = undefined;
|
||||||
}
|
}
|
||||||
this.pageLoadingText = undefined;
|
this.pageLoadingText = undefined;
|
||||||
|
this.campaignObjectiveJournal?.destroy();
|
||||||
|
this.campaignObjectiveJournal = undefined;
|
||||||
this.clearStoryEnvironment();
|
this.clearStoryEnvironment();
|
||||||
this.releaseOwnedStoryTextures();
|
this.releaseOwnedStoryTextures();
|
||||||
});
|
});
|
||||||
@@ -445,10 +455,33 @@ export class StoryScene extends Phaser.Scene {
|
|||||||
this.drawSceneShade(width, height);
|
this.drawSceneShade(width, height);
|
||||||
this.drawDialogPanel(width, height);
|
this.drawDialogPanel(width, height);
|
||||||
this.showPage(0, true);
|
this.showPage(0, true);
|
||||||
|
this.campaignObjectiveJournal = new CampaignObjectiveJournalOverlay(
|
||||||
|
this,
|
||||||
|
{
|
||||||
|
snapshotProvider: () =>
|
||||||
|
resolveCampaignObjectiveJournal(getCampaignState()),
|
||||||
|
canOpen: () => !this.transitioning && !this.loadingText,
|
||||||
|
buttonX: 1215,
|
||||||
|
buttonY: 21
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
this.input.on('pointerdown', () => this.advance());
|
this.input.on('pointerdown', () => this.advance());
|
||||||
this.input.keyboard?.on('keydown-SPACE', () => this.advance());
|
this.input.keyboard?.on('keydown-SPACE', () => this.advance());
|
||||||
this.input.keyboard?.on('keydown-ENTER', () => this.advance());
|
this.input.keyboard?.on('keydown-ENTER', () => this.advance());
|
||||||
|
this.input.keyboard?.on(
|
||||||
|
'keydown-J',
|
||||||
|
(event: KeyboardEvent) => {
|
||||||
|
if (!event.repeat) {
|
||||||
|
this.campaignObjectiveJournal?.toggle();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
this.input.keyboard?.on('keydown-ESC', () => {
|
||||||
|
if (this.campaignObjectiveJournal?.isOpen()) {
|
||||||
|
this.campaignObjectiveJournal.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
}, true);
|
}, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2082,7 +2115,7 @@ export class StoryScene extends Phaser.Scene {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private advance() {
|
private advance() {
|
||||||
if (this.transitioning) {
|
if (this.transitioning || this.campaignObjectiveJournal?.isOpen()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import {
|
|||||||
startNewCampaign,
|
startNewCampaign,
|
||||||
type CampaignSaveSlotSummary
|
type CampaignSaveSlotSummary
|
||||||
} from '../state/campaignState';
|
} from '../state/campaignState';
|
||||||
|
import { resolveCampaignObjectiveJournal } from '../state/campaignObjectiveJournal';
|
||||||
import { palette } from '../ui/palette';
|
import { palette } from '../ui/palette';
|
||||||
import { startLazyScene, type LazySceneKey } from './lazyScenes';
|
import { startLazyScene, type LazySceneKey } from './lazyScenes';
|
||||||
|
|
||||||
@@ -460,8 +461,8 @@ export class TitleScene extends Phaser.Scene {
|
|||||||
return this.compactSaveDetail(`${Object.keys(campaign.battleHistory).length}전 완료 · ${campaign.roster.length}명 합류`);
|
return this.compactSaveDetail(`${Object.keys(campaign.battleHistory).length}전 완료 · ${campaign.roster.length}명 합류`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const progress = summarizeCampaignProgress(campaign);
|
const journal = resolveCampaignObjectiveJournal(campaign);
|
||||||
return this.compactSaveDetail(`${progress.meta} · ${progress.title}`);
|
return this.compactSaveDetail(`◆ ${journal.current.title}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
private compactSaveDetail(label: string) {
|
private compactSaveDetail(label: string) {
|
||||||
|
|||||||
1072
src/game/state/campaignObjectiveJournal.ts
Normal file
1072
src/game/state/campaignObjectiveJournal.ts
Normal file
File diff suppressed because it is too large
Load Diff
942
src/game/ui/CampaignObjectiveJournalOverlay.ts
Normal file
942
src/game/ui/CampaignObjectiveJournalOverlay.ts
Normal file
@@ -0,0 +1,942 @@
|
|||||||
|
import type Phaser from 'phaser';
|
||||||
|
|
||||||
|
const viewportWidth = 1920;
|
||||||
|
const viewportHeight = 1080;
|
||||||
|
const overlayDepth = 5100;
|
||||||
|
const buttonDepth = 5001;
|
||||||
|
const panelWidth = 1120;
|
||||||
|
const panelHeight = 680;
|
||||||
|
const panelX = (viewportWidth - panelWidth) / 2;
|
||||||
|
const panelY = (viewportHeight - panelHeight) / 2;
|
||||||
|
const buttonWidth = 240;
|
||||||
|
const buttonHeight = 50;
|
||||||
|
const cardX = panelX + 44;
|
||||||
|
const cardWidth = panelWidth - 88;
|
||||||
|
const cardHeight = 140;
|
||||||
|
|
||||||
|
const fontFamily = '"Malgun Gothic", "Noto Sans KR", sans-serif';
|
||||||
|
|
||||||
|
type PointerEventData = {
|
||||||
|
stopPropagation: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CampaignObjectiveJournalStatus =
|
||||||
|
| 'current'
|
||||||
|
| 'completed'
|
||||||
|
| 'clue'
|
||||||
|
| 'locked'
|
||||||
|
| 'warning'
|
||||||
|
| 'neutral'
|
||||||
|
| (string & {});
|
||||||
|
|
||||||
|
export interface CampaignObjectiveJournalEntry {
|
||||||
|
status: CampaignObjectiveJournalStatus;
|
||||||
|
statusLabel: string;
|
||||||
|
title: string;
|
||||||
|
detail: string;
|
||||||
|
location?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CampaignObjectiveJournalProgress {
|
||||||
|
label?: string;
|
||||||
|
title?: string;
|
||||||
|
detail?: string;
|
||||||
|
statusLabel?: string;
|
||||||
|
current?: number;
|
||||||
|
completed?: number;
|
||||||
|
total?: number;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CampaignObjectiveJournalResumeContext {
|
||||||
|
title?: string;
|
||||||
|
summary?: string;
|
||||||
|
lines?: readonly string[];
|
||||||
|
detail?: string;
|
||||||
|
previousEvent?: string;
|
||||||
|
currentReason?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CampaignObjectiveJournalSnapshot {
|
||||||
|
contextKind: string;
|
||||||
|
current: CampaignObjectiveJournalEntry | null;
|
||||||
|
recentCompleted: CampaignObjectiveJournalEntry | null;
|
||||||
|
nextClue: CampaignObjectiveJournalEntry | null;
|
||||||
|
progress?: CampaignObjectiveJournalProgress | string | number | null;
|
||||||
|
resumeContext?:
|
||||||
|
| CampaignObjectiveJournalResumeContext
|
||||||
|
| string
|
||||||
|
| null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CampaignObjectiveJournalOverlayOptions {
|
||||||
|
snapshotProvider: () => CampaignObjectiveJournalSnapshot;
|
||||||
|
canOpen?: boolean | (() => boolean);
|
||||||
|
buttonX?: number;
|
||||||
|
buttonY?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CampaignObjectiveJournalBounds {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
right: number;
|
||||||
|
bottom: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CampaignObjectiveJournalCardKind =
|
||||||
|
| 'current'
|
||||||
|
| 'completed'
|
||||||
|
| 'nextClue';
|
||||||
|
|
||||||
|
export interface CampaignObjectiveJournalCardDebugState {
|
||||||
|
kind: CampaignObjectiveJournalCardKind;
|
||||||
|
label: string;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
bounds: CampaignObjectiveJournalBounds;
|
||||||
|
titleBounds: CampaignObjectiveJournalBounds;
|
||||||
|
bodyBounds: CampaignObjectiveJournalBounds;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CampaignObjectiveJournalDebugState {
|
||||||
|
visible: boolean;
|
||||||
|
shortcut: 'J';
|
||||||
|
objectCount: number;
|
||||||
|
buttonBounds: CampaignObjectiveJournalBounds;
|
||||||
|
panelBounds: CampaignObjectiveJournalBounds | null;
|
||||||
|
closeBounds: CampaignObjectiveJournalBounds | null;
|
||||||
|
cards: CampaignObjectiveJournalCardDebugState[];
|
||||||
|
visibleText: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type CardPresentation = {
|
||||||
|
kind: CampaignObjectiveJournalCardKind;
|
||||||
|
label: string;
|
||||||
|
entry: CampaignObjectiveJournalEntry;
|
||||||
|
fill: number;
|
||||||
|
stroke: number;
|
||||||
|
labelColor: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CardView = {
|
||||||
|
kind: CampaignObjectiveJournalCardKind;
|
||||||
|
label: string;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
background: Phaser.GameObjects.Rectangle;
|
||||||
|
titleText: Phaser.GameObjects.Text;
|
||||||
|
bodyText: Phaser.GameObjects.Text;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fallbackEntries: Record<
|
||||||
|
CampaignObjectiveJournalCardKind,
|
||||||
|
CampaignObjectiveJournalEntry
|
||||||
|
> = {
|
||||||
|
current: {
|
||||||
|
status: 'neutral',
|
||||||
|
statusLabel: '확인 중',
|
||||||
|
title: '현재 목표를 확인하는 중입니다',
|
||||||
|
detail: '장면을 이어 진행하면 즉시 할 일이 갱신됩니다.'
|
||||||
|
},
|
||||||
|
completed: {
|
||||||
|
status: 'neutral',
|
||||||
|
statusLabel: '기록 없음',
|
||||||
|
title: '아직 마친 기록이 없습니다',
|
||||||
|
detail: '첫 목표를 완료하면 가장 최근 기록이 이곳에 남습니다.'
|
||||||
|
},
|
||||||
|
nextClue: {
|
||||||
|
status: 'locked',
|
||||||
|
statusLabel: '미공개',
|
||||||
|
title: '다음 실마리는 아직 드러나지 않았습니다',
|
||||||
|
detail: '현재 목표를 진행하면 공개된 정보만 바탕으로 갱신됩니다.'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 1920×1080 데스크톱 장면에서 공통으로 사용하는 캠페인 목표 기록 오버레이입니다.
|
||||||
|
*
|
||||||
|
* 키 입력은 의도적으로 등록하지 않습니다. 소유 장면이 J/Esc 입력을 받아
|
||||||
|
* toggle/open/close를 호출해야 장면별 키 충돌과 모달 우선순위를 제어할 수 있습니다.
|
||||||
|
*/
|
||||||
|
export class CampaignObjectiveJournalOverlay {
|
||||||
|
private readonly scene: Phaser.Scene;
|
||||||
|
private readonly options: CampaignObjectiveJournalOverlayOptions;
|
||||||
|
private readonly buttonX: number;
|
||||||
|
private readonly buttonY: number;
|
||||||
|
private readonly buttonRoot: Phaser.GameObjects.Container;
|
||||||
|
private readonly buttonBackground: Phaser.GameObjects.Rectangle;
|
||||||
|
private readonly buttonLabel: Phaser.GameObjects.Text;
|
||||||
|
private overlayRoot?: Phaser.GameObjects.Container;
|
||||||
|
private panelBackground?: Phaser.GameObjects.Rectangle;
|
||||||
|
private closeBackground?: Phaser.GameObjects.Rectangle;
|
||||||
|
private overlayTexts: Phaser.GameObjects.Text[] = [];
|
||||||
|
private cardViews: CardView[] = [];
|
||||||
|
private destroyed = false;
|
||||||
|
|
||||||
|
private readonly handleSceneEnd = () => {
|
||||||
|
this.destroy();
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
scene: Phaser.Scene,
|
||||||
|
options: CampaignObjectiveJournalOverlayOptions
|
||||||
|
) {
|
||||||
|
this.scene = scene;
|
||||||
|
this.options = options;
|
||||||
|
this.buttonX = options.buttonX ?? 1325;
|
||||||
|
this.buttonY = options.buttonY ?? 44;
|
||||||
|
|
||||||
|
this.buttonRoot = scene.add
|
||||||
|
.container(0, 0)
|
||||||
|
.setDepth(buttonDepth)
|
||||||
|
.setScrollFactor(0);
|
||||||
|
|
||||||
|
const buttonShadow = scene.add
|
||||||
|
.rectangle(
|
||||||
|
this.buttonX + 4,
|
||||||
|
this.buttonY + 5,
|
||||||
|
buttonWidth,
|
||||||
|
buttonHeight,
|
||||||
|
0x05080d,
|
||||||
|
0.62
|
||||||
|
)
|
||||||
|
.setOrigin(0);
|
||||||
|
this.buttonRoot.add(buttonShadow);
|
||||||
|
|
||||||
|
this.buttonBackground = scene.add
|
||||||
|
.rectangle(
|
||||||
|
this.buttonX,
|
||||||
|
this.buttonY,
|
||||||
|
buttonWidth,
|
||||||
|
buttonHeight,
|
||||||
|
0x162331,
|
||||||
|
0.97
|
||||||
|
)
|
||||||
|
.setOrigin(0)
|
||||||
|
.setStrokeStyle(2, 0xd8b15f, 0.92)
|
||||||
|
.setInteractive({ useHandCursor: true });
|
||||||
|
this.buttonRoot.add(this.buttonBackground);
|
||||||
|
|
||||||
|
const buttonAccent = scene.add
|
||||||
|
.rectangle(this.buttonX, this.buttonY, 7, buttonHeight, 0xd8b15f, 1)
|
||||||
|
.setOrigin(0);
|
||||||
|
this.buttonRoot.add(buttonAccent);
|
||||||
|
|
||||||
|
this.buttonLabel = scene.add
|
||||||
|
.text(
|
||||||
|
this.buttonX + buttonWidth / 2,
|
||||||
|
this.buttonY + buttonHeight / 2,
|
||||||
|
'J 진군 기록',
|
||||||
|
{
|
||||||
|
fontFamily,
|
||||||
|
fontSize: '21px',
|
||||||
|
color: '#f4e7c7',
|
||||||
|
fontStyle: 'bold',
|
||||||
|
align: 'center'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.setOrigin(0.5);
|
||||||
|
this.buttonRoot.add(this.buttonLabel);
|
||||||
|
|
||||||
|
this.buttonBackground.on('pointerover', () => {
|
||||||
|
const enabled = this.canOpen();
|
||||||
|
this.buttonBackground.setFillStyle(
|
||||||
|
enabled ? 0x24384b : 0x1a2028,
|
||||||
|
enabled ? 1 : 0.84
|
||||||
|
);
|
||||||
|
this.buttonLabel.setColor(enabled ? '#fff2cf' : '#8f969f');
|
||||||
|
});
|
||||||
|
this.buttonBackground.on('pointerout', () => {
|
||||||
|
this.refreshButtonPresentation();
|
||||||
|
});
|
||||||
|
this.buttonBackground.on(
|
||||||
|
'pointerdown',
|
||||||
|
(
|
||||||
|
_pointer: Phaser.Input.Pointer,
|
||||||
|
_localX: number,
|
||||||
|
_localY: number,
|
||||||
|
event: PointerEventData
|
||||||
|
) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
if (this.canOpen()) {
|
||||||
|
this.toggle();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
this.refreshButtonPresentation();
|
||||||
|
scene.events.once('shutdown', this.handleSceneEnd);
|
||||||
|
scene.events.once('destroy', this.handleSceneEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
toggle(): boolean {
|
||||||
|
if (this.isOpen()) {
|
||||||
|
this.close();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.open();
|
||||||
|
}
|
||||||
|
|
||||||
|
open(): boolean {
|
||||||
|
if (this.destroyed || this.isOpen() || !this.canOpen()) {
|
||||||
|
return this.isOpen();
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapshot = this.options.snapshotProvider();
|
||||||
|
this.createOverlay(snapshot);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
close(): boolean {
|
||||||
|
if (!this.overlayRoot) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.overlayRoot.destroy(true);
|
||||||
|
this.overlayRoot = undefined;
|
||||||
|
this.panelBackground = undefined;
|
||||||
|
this.closeBackground = undefined;
|
||||||
|
this.overlayTexts = [];
|
||||||
|
this.cardViews = [];
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
isOpen(): boolean {
|
||||||
|
return Boolean(
|
||||||
|
this.overlayRoot?.active &&
|
||||||
|
this.overlayRoot.visible &&
|
||||||
|
!this.destroyed
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
getDebugState(): CampaignObjectiveJournalDebugState {
|
||||||
|
const visible = this.isOpen();
|
||||||
|
const buttonBounds = this.buttonBackground.active
|
||||||
|
? snapshotBounds(this.buttonBackground.getBounds())
|
||||||
|
: boundsFromValues(
|
||||||
|
this.buttonX,
|
||||||
|
this.buttonY,
|
||||||
|
buttonWidth,
|
||||||
|
buttonHeight
|
||||||
|
);
|
||||||
|
const visibleText = [
|
||||||
|
this.buttonLabel,
|
||||||
|
...this.overlayTexts
|
||||||
|
]
|
||||||
|
.filter((text) => text.active && text.visible)
|
||||||
|
.map((text) => text.text);
|
||||||
|
|
||||||
|
return {
|
||||||
|
visible,
|
||||||
|
shortcut: 'J',
|
||||||
|
objectCount: visible
|
||||||
|
? 1 + this.cardViews.length
|
||||||
|
: 0,
|
||||||
|
buttonBounds,
|
||||||
|
panelBounds:
|
||||||
|
visible && this.panelBackground
|
||||||
|
? snapshotBounds(this.panelBackground.getBounds())
|
||||||
|
: null,
|
||||||
|
closeBounds:
|
||||||
|
visible && this.closeBackground
|
||||||
|
? snapshotBounds(this.closeBackground.getBounds())
|
||||||
|
: null,
|
||||||
|
cards: visible
|
||||||
|
? this.cardViews.map((card) => ({
|
||||||
|
kind: card.kind,
|
||||||
|
label: card.label,
|
||||||
|
title: card.title,
|
||||||
|
body: card.body,
|
||||||
|
bounds: snapshotBounds(card.background.getBounds()),
|
||||||
|
titleBounds: snapshotBounds(card.titleText.getBounds()),
|
||||||
|
bodyBounds: snapshotBounds(card.bodyText.getBounds())
|
||||||
|
}))
|
||||||
|
: [],
|
||||||
|
visibleText
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
if (this.destroyed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.destroyed = true;
|
||||||
|
this.scene.events.off('shutdown', this.handleSceneEnd);
|
||||||
|
this.scene.events.off('destroy', this.handleSceneEnd);
|
||||||
|
this.close();
|
||||||
|
this.buttonRoot.destroy(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private canOpen() {
|
||||||
|
const predicate = this.options.canOpen;
|
||||||
|
return typeof predicate === 'function'
|
||||||
|
? predicate()
|
||||||
|
: predicate ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private refreshButtonPresentation() {
|
||||||
|
if (!this.buttonBackground.active || !this.buttonLabel.active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const enabled = this.canOpen();
|
||||||
|
this.buttonBackground.setFillStyle(
|
||||||
|
enabled ? 0x162331 : 0x1a2028,
|
||||||
|
enabled ? 0.97 : 0.78
|
||||||
|
);
|
||||||
|
this.buttonLabel.setColor(enabled ? '#f4e7c7' : '#8f969f');
|
||||||
|
this.buttonRoot.setAlpha(enabled ? 1 : 0.78);
|
||||||
|
}
|
||||||
|
|
||||||
|
private createOverlay(snapshot: CampaignObjectiveJournalSnapshot) {
|
||||||
|
const root = this.scene.add
|
||||||
|
.container(0, 0)
|
||||||
|
.setDepth(overlayDepth)
|
||||||
|
.setScrollFactor(0);
|
||||||
|
this.overlayRoot = root;
|
||||||
|
|
||||||
|
const blocker = this.scene.add
|
||||||
|
.rectangle(
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
viewportWidth,
|
||||||
|
viewportHeight,
|
||||||
|
0x03070c,
|
||||||
|
0.76
|
||||||
|
)
|
||||||
|
.setOrigin(0)
|
||||||
|
.setInteractive();
|
||||||
|
blocker.on(
|
||||||
|
'pointerdown',
|
||||||
|
(
|
||||||
|
_pointer: Phaser.Input.Pointer,
|
||||||
|
_localX: number,
|
||||||
|
_localY: number,
|
||||||
|
event: PointerEventData
|
||||||
|
) => event.stopPropagation()
|
||||||
|
);
|
||||||
|
blocker.on(
|
||||||
|
'wheel',
|
||||||
|
(
|
||||||
|
_pointer: Phaser.Input.Pointer,
|
||||||
|
_deltaX: number,
|
||||||
|
_deltaY: number,
|
||||||
|
_deltaZ: number,
|
||||||
|
event: PointerEventData
|
||||||
|
) => event.stopPropagation()
|
||||||
|
);
|
||||||
|
root.add(blocker);
|
||||||
|
|
||||||
|
const panelShadow = this.scene.add
|
||||||
|
.rectangle(
|
||||||
|
panelX + 12,
|
||||||
|
panelY + 16,
|
||||||
|
panelWidth,
|
||||||
|
panelHeight,
|
||||||
|
0x000000,
|
||||||
|
0.48
|
||||||
|
)
|
||||||
|
.setOrigin(0);
|
||||||
|
root.add(panelShadow);
|
||||||
|
|
||||||
|
const panel = this.scene.add
|
||||||
|
.rectangle(
|
||||||
|
panelX,
|
||||||
|
panelY,
|
||||||
|
panelWidth,
|
||||||
|
panelHeight,
|
||||||
|
0x111923,
|
||||||
|
0.995
|
||||||
|
)
|
||||||
|
.setOrigin(0)
|
||||||
|
.setStrokeStyle(3, 0xd8b15f, 0.92)
|
||||||
|
.setInteractive();
|
||||||
|
panel.on(
|
||||||
|
'pointerdown',
|
||||||
|
(
|
||||||
|
_pointer: Phaser.Input.Pointer,
|
||||||
|
_localX: number,
|
||||||
|
_localY: number,
|
||||||
|
event: PointerEventData
|
||||||
|
) => event.stopPropagation()
|
||||||
|
);
|
||||||
|
root.add(panel);
|
||||||
|
this.panelBackground = panel;
|
||||||
|
|
||||||
|
const topAccent = this.scene.add
|
||||||
|
.rectangle(panelX, panelY, panelWidth, 8, 0xd8b15f, 0.96)
|
||||||
|
.setOrigin(0);
|
||||||
|
root.add(topAccent);
|
||||||
|
|
||||||
|
this.addOverlayText(
|
||||||
|
root,
|
||||||
|
panelX + 44,
|
||||||
|
panelY + 28,
|
||||||
|
'진군 기록',
|
||||||
|
{
|
||||||
|
fontSize: '30px',
|
||||||
|
color: '#f4e7c7',
|
||||||
|
fontStyle: 'bold'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
this.addOverlayText(
|
||||||
|
root,
|
||||||
|
panelX + 230,
|
||||||
|
panelY + 38,
|
||||||
|
`진행 맥락 · ${formatContextKind(snapshot.contextKind)}`,
|
||||||
|
{
|
||||||
|
fontSize: '18px',
|
||||||
|
color: '#aebccc'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const close = this.scene.add
|
||||||
|
.rectangle(
|
||||||
|
panelX + panelWidth - 140,
|
||||||
|
panelY + 24,
|
||||||
|
96,
|
||||||
|
44,
|
||||||
|
0x273442,
|
||||||
|
0.98
|
||||||
|
)
|
||||||
|
.setOrigin(0)
|
||||||
|
.setStrokeStyle(2, 0x8192a5, 0.82)
|
||||||
|
.setInteractive({ useHandCursor: true });
|
||||||
|
close.on('pointerover', () => close.setFillStyle(0x3a4a5b, 1));
|
||||||
|
close.on('pointerout', () => close.setFillStyle(0x273442, 0.98));
|
||||||
|
close.on(
|
||||||
|
'pointerdown',
|
||||||
|
(
|
||||||
|
_pointer: Phaser.Input.Pointer,
|
||||||
|
_localX: number,
|
||||||
|
_localY: number,
|
||||||
|
event: PointerEventData
|
||||||
|
) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
root.add(close);
|
||||||
|
this.closeBackground = close;
|
||||||
|
|
||||||
|
const closeLabel = this.addOverlayText(
|
||||||
|
root,
|
||||||
|
panelX + panelWidth - 92,
|
||||||
|
panelY + 46,
|
||||||
|
'닫기',
|
||||||
|
{
|
||||||
|
fontSize: '18px',
|
||||||
|
color: '#edf2f6',
|
||||||
|
fontStyle: 'bold',
|
||||||
|
align: 'center'
|
||||||
|
}
|
||||||
|
).setOrigin(0.5);
|
||||||
|
|
||||||
|
const progressText = formatProgress(snapshot.progress);
|
||||||
|
this.addOverlayText(
|
||||||
|
root,
|
||||||
|
panelX + 44,
|
||||||
|
panelY + 82,
|
||||||
|
progressText,
|
||||||
|
{
|
||||||
|
fontSize: '20px',
|
||||||
|
color: '#e4c77f',
|
||||||
|
fontStyle: 'bold'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const resumeText = formatResumeContext(snapshot.resumeContext);
|
||||||
|
if (resumeText) {
|
||||||
|
this.addOverlayText(
|
||||||
|
root,
|
||||||
|
panelX + 44,
|
||||||
|
panelY + 114,
|
||||||
|
`재개 맥락 · ${compactText(resumeText, 112)}`,
|
||||||
|
{
|
||||||
|
fontSize: '18px',
|
||||||
|
color: '#bcc7d2',
|
||||||
|
wordWrap: { width: panelWidth - 88, useAdvancedWrap: true },
|
||||||
|
maxLines: 2
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const divider = this.scene.add
|
||||||
|
.rectangle(
|
||||||
|
panelX + 44,
|
||||||
|
panelY + 158,
|
||||||
|
panelWidth - 88,
|
||||||
|
2,
|
||||||
|
0x536276,
|
||||||
|
0.7
|
||||||
|
)
|
||||||
|
.setOrigin(0);
|
||||||
|
root.add(divider);
|
||||||
|
|
||||||
|
const cards: CardPresentation[] = [
|
||||||
|
{
|
||||||
|
kind: 'current',
|
||||||
|
label: '◆ 지금 할 일',
|
||||||
|
entry: snapshot.current ?? fallbackEntries.current,
|
||||||
|
fill: 0x1b2936,
|
||||||
|
stroke: 0xd8b15f,
|
||||||
|
labelColor: '#f0cf82'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'completed',
|
||||||
|
label: '✓ 마친 일',
|
||||||
|
entry:
|
||||||
|
snapshot.recentCompleted ?? fallbackEntries.completed,
|
||||||
|
fill: 0x182a28,
|
||||||
|
stroke: 0x70a978,
|
||||||
|
labelColor: '#9dd0a3'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'nextClue',
|
||||||
|
label: '? 다음 실마리',
|
||||||
|
entry: snapshot.nextClue ?? fallbackEntries.nextClue,
|
||||||
|
fill: 0x192532,
|
||||||
|
stroke: 0x6f92be,
|
||||||
|
labelColor: '#a9c6e9'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
cards.forEach((card, index) => {
|
||||||
|
this.createCard(root, card, panelY + 174 + index * 154);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.addOverlayText(
|
||||||
|
root,
|
||||||
|
panelX + 44,
|
||||||
|
panelY + panelHeight - 30,
|
||||||
|
'공개된 진행 정보만 기록됩니다 · 장면에서 J 또는 Esc로 닫기',
|
||||||
|
{
|
||||||
|
fontSize: '18px',
|
||||||
|
color: '#8f9cab'
|
||||||
|
}
|
||||||
|
).setOrigin(0, 1);
|
||||||
|
|
||||||
|
// The label is intentionally tracked even though it is non-interactive:
|
||||||
|
// browser QA can assert that the visible close affordance matches its hit box.
|
||||||
|
void closeLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
private createCard(
|
||||||
|
root: Phaser.GameObjects.Container,
|
||||||
|
card: CardPresentation,
|
||||||
|
y: number
|
||||||
|
) {
|
||||||
|
const background = this.scene.add
|
||||||
|
.rectangle(
|
||||||
|
cardX,
|
||||||
|
y,
|
||||||
|
cardWidth,
|
||||||
|
cardHeight,
|
||||||
|
card.fill,
|
||||||
|
0.98
|
||||||
|
)
|
||||||
|
.setOrigin(0)
|
||||||
|
.setStrokeStyle(2, card.stroke, 0.9);
|
||||||
|
root.add(background);
|
||||||
|
|
||||||
|
const accent = this.scene.add
|
||||||
|
.rectangle(cardX, y, 7, cardHeight, card.stroke, 1)
|
||||||
|
.setOrigin(0);
|
||||||
|
root.add(accent);
|
||||||
|
|
||||||
|
this.addOverlayText(
|
||||||
|
root,
|
||||||
|
cardX + 24,
|
||||||
|
y + 14,
|
||||||
|
card.label,
|
||||||
|
{
|
||||||
|
fontSize: '20px',
|
||||||
|
color: card.labelColor,
|
||||||
|
fontStyle: 'bold'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const locationSuffix = cleanText(card.entry.location);
|
||||||
|
const statusLabel =
|
||||||
|
cleanText(card.entry.statusLabel) ||
|
||||||
|
fallbackEntries[card.kind].statusLabel;
|
||||||
|
this.addOverlayText(
|
||||||
|
root,
|
||||||
|
cardX + cardWidth - 304,
|
||||||
|
y + 14,
|
||||||
|
compactText(
|
||||||
|
locationSuffix
|
||||||
|
? `${statusLabel} · ${locationSuffix}`
|
||||||
|
: statusLabel,
|
||||||
|
30
|
||||||
|
),
|
||||||
|
{
|
||||||
|
fontSize: '18px',
|
||||||
|
color: statusColor(card.entry.status, card.labelColor),
|
||||||
|
fontStyle: 'bold',
|
||||||
|
fixedWidth: 278,
|
||||||
|
align: 'right'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const title = compactText(
|
||||||
|
cleanText(card.entry.title) ||
|
||||||
|
fallbackEntries[card.kind].title,
|
||||||
|
52
|
||||||
|
);
|
||||||
|
const body = compactText(
|
||||||
|
cleanText(card.entry.detail) ||
|
||||||
|
fallbackEntries[card.kind].detail,
|
||||||
|
112
|
||||||
|
);
|
||||||
|
|
||||||
|
const titleText = this.addOverlayText(
|
||||||
|
root,
|
||||||
|
cardX + 24,
|
||||||
|
y + 48,
|
||||||
|
title,
|
||||||
|
{
|
||||||
|
fontSize: '24px',
|
||||||
|
color: '#f1eadb',
|
||||||
|
fontStyle: 'bold',
|
||||||
|
fixedWidth: cardWidth - 48,
|
||||||
|
maxLines: 1
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const bodyText = this.addOverlayText(
|
||||||
|
root,
|
||||||
|
cardX + 24,
|
||||||
|
y + 84,
|
||||||
|
body,
|
||||||
|
{
|
||||||
|
fontSize: '18px',
|
||||||
|
color: '#c4ced7',
|
||||||
|
fixedWidth: cardWidth - 48,
|
||||||
|
wordWrap: {
|
||||||
|
width: cardWidth - 48,
|
||||||
|
useAdvancedWrap: true
|
||||||
|
},
|
||||||
|
maxLines: 2,
|
||||||
|
lineSpacing: 4
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
this.cardViews.push({
|
||||||
|
kind: card.kind,
|
||||||
|
label: card.label,
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
background,
|
||||||
|
titleText,
|
||||||
|
bodyText
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private addOverlayText(
|
||||||
|
root: Phaser.GameObjects.Container,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
value: string,
|
||||||
|
style: Phaser.Types.GameObjects.Text.TextStyle
|
||||||
|
) {
|
||||||
|
const text = this.scene.add.text(x, y, value, {
|
||||||
|
fontFamily,
|
||||||
|
...style
|
||||||
|
});
|
||||||
|
root.add(text);
|
||||||
|
this.overlayTexts.push(text);
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatContextKind(contextKind: string) {
|
||||||
|
const normalized = cleanText(contextKind);
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
battle: '전장',
|
||||||
|
camp: '군영',
|
||||||
|
story: '이야기',
|
||||||
|
cityStay: '성 체류',
|
||||||
|
'city-stay': '성 체류',
|
||||||
|
'not-started': '여정 시작 전',
|
||||||
|
'village-exploration': '의용군 마을',
|
||||||
|
'militia-camp-exploration': '의용군 막사',
|
||||||
|
'battle-preparation': '출진 준비',
|
||||||
|
aftermath: '승리 후 이야기',
|
||||||
|
'interlude-exploration': '전투 사이 탐색',
|
||||||
|
prologueVillage: '의용군 마을',
|
||||||
|
'prologue-village': '의용군 마을',
|
||||||
|
prologueMilitiaCamp: '의용군 막사',
|
||||||
|
'prologue-militia-camp': '의용군 막사',
|
||||||
|
campVisit: '전투 사이 탐색',
|
||||||
|
'camp-visit': '전투 사이 탐색',
|
||||||
|
title: '저장 기록',
|
||||||
|
ending: '대단원'
|
||||||
|
};
|
||||||
|
|
||||||
|
return labels[normalized] ?? (normalized || '캠페인');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatProgress(
|
||||||
|
progress:
|
||||||
|
| CampaignObjectiveJournalProgress
|
||||||
|
| string
|
||||||
|
| number
|
||||||
|
| null
|
||||||
|
| undefined
|
||||||
|
) {
|
||||||
|
if (typeof progress === 'string') {
|
||||||
|
return `전장 진행 · ${compactText(progress, 72)}`;
|
||||||
|
}
|
||||||
|
if (typeof progress === 'number' && Number.isFinite(progress)) {
|
||||||
|
return `전장 진행 · ${progress}`;
|
||||||
|
}
|
||||||
|
if (!progress || typeof progress !== 'object') {
|
||||||
|
return '전장 진행 · 기록 확인 중';
|
||||||
|
}
|
||||||
|
|
||||||
|
const label =
|
||||||
|
firstText(
|
||||||
|
progress.label,
|
||||||
|
progress.title,
|
||||||
|
progress.statusLabel
|
||||||
|
) ?? '전장 진행';
|
||||||
|
const current = firstFiniteNumber(
|
||||||
|
progress.current,
|
||||||
|
progress.completed,
|
||||||
|
progress.completedBattles,
|
||||||
|
progress.battle
|
||||||
|
);
|
||||||
|
const total = firstFiniteNumber(
|
||||||
|
progress.total,
|
||||||
|
progress.max,
|
||||||
|
progress.totalBattles
|
||||||
|
);
|
||||||
|
const detail = firstText(progress.detail);
|
||||||
|
|
||||||
|
if (current !== undefined && total !== undefined) {
|
||||||
|
return `${compactText(label, 44)} · ${current} / ${total}`;
|
||||||
|
}
|
||||||
|
if (current !== undefined) {
|
||||||
|
return `${compactText(label, 54)} · ${current}`;
|
||||||
|
}
|
||||||
|
if (detail) {
|
||||||
|
return `${compactText(label, 34)} · ${compactText(detail, 52)}`;
|
||||||
|
}
|
||||||
|
return compactText(label, 72);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatResumeContext(
|
||||||
|
context:
|
||||||
|
| CampaignObjectiveJournalResumeContext
|
||||||
|
| string
|
||||||
|
| null
|
||||||
|
| undefined
|
||||||
|
) {
|
||||||
|
if (typeof context === 'string') {
|
||||||
|
return cleanText(context);
|
||||||
|
}
|
||||||
|
if (!context || typeof context !== 'object') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = [
|
||||||
|
firstText(context.title),
|
||||||
|
firstText(context.summary),
|
||||||
|
firstText(context.detail),
|
||||||
|
firstText(context.previousEvent)
|
||||||
|
? `지난 기록 ${firstText(context.previousEvent)}`
|
||||||
|
: undefined,
|
||||||
|
firstText(context.currentReason)
|
||||||
|
? `현재 ${firstText(context.currentReason)}`
|
||||||
|
: undefined
|
||||||
|
].filter((part): part is string => Boolean(part));
|
||||||
|
|
||||||
|
return Array.from(new Set(parts)).join(' · ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusColor(
|
||||||
|
status: CampaignObjectiveJournalStatus,
|
||||||
|
fallbackColor: string
|
||||||
|
) {
|
||||||
|
switch (status) {
|
||||||
|
case 'completed':
|
||||||
|
return '#a8d6ad';
|
||||||
|
case 'warning':
|
||||||
|
return '#f0b27f';
|
||||||
|
case 'locked':
|
||||||
|
return '#a7b0bb';
|
||||||
|
case 'current':
|
||||||
|
return '#f0cf82';
|
||||||
|
case 'clue':
|
||||||
|
return '#adc9e8';
|
||||||
|
default:
|
||||||
|
return fallbackColor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstText(...values: unknown[]) {
|
||||||
|
for (const value of values) {
|
||||||
|
const text = cleanText(value);
|
||||||
|
if (text) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstFiniteNumber(...values: unknown[]) {
|
||||||
|
for (const value of values) {
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanText(value: unknown) {
|
||||||
|
return typeof value === 'string'
|
||||||
|
? value.replace(/\s+/g, ' ').trim()
|
||||||
|
: '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function compactText(value: string, limit: number) {
|
||||||
|
const text = cleanText(value);
|
||||||
|
if (text.length <= limit) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
return `${text.slice(0, Math.max(1, limit - 1)).trimEnd()}…`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshotBounds(
|
||||||
|
bounds: Phaser.Geom.Rectangle
|
||||||
|
): CampaignObjectiveJournalBounds {
|
||||||
|
return boundsFromValues(
|
||||||
|
bounds.x,
|
||||||
|
bounds.y,
|
||||||
|
bounds.width,
|
||||||
|
bounds.height
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function boundsFromValues(
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
width: number,
|
||||||
|
height: number
|
||||||
|
): CampaignObjectiveJournalBounds {
|
||||||
|
return {
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
right: x + width,
|
||||||
|
bottom: y + height
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user