feat: add spoiler-safe campaign objective journal
This commit is contained in:
@@ -317,10 +317,41 @@ RPG로 다듬는다.
|
||||
전투 예보와 일치하는지를 Canvas와 WebGL 모두 1920x1080 CSS viewport,
|
||||
100% 확대, DPR 1에서 자동 검증하는 계약으로 고정한다.
|
||||
|
||||
다음 묶음은
|
||||
[Xbox 목표 명료성 지침](https://learn.microsoft.com/en-us/xbox/accessibility/xbox-accessibility-guidelines/109)에
|
||||
맞춰 현재 목표·완료 목표·다음 목표를 어디에서 다시 확인할 수 있는지
|
||||
캠페인 전반에서 감사한다.
|
||||
### 완료한 16차 묶음: 길을 잃지 않고 미래를 미리 보지 않는 진군 기록
|
||||
|
||||
- 현재 목표를 언제든 다시 보고 완료 기록을 구분해 보존하라는
|
||||
[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-completion": "node scripts/verify-campaign-completion.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:battle-save": "node scripts/verify-battle-save-normalization.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:save-flow": "node scripts/verify-save-retry-flow.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:public-deploy": "node scripts/verify-public-deploy.mjs",
|
||||
"qa:representative": "node scripts/qa-representative-battles.mjs",
|
||||
|
||||
@@ -1348,7 +1348,7 @@ async function setupBattle(page, battle) {
|
||||
return (
|
||||
state?.scene === 'BattleScene' &&
|
||||
state?.battleId === battleId &&
|
||||
(state?.phase === 'deployment' || state?.phase === 'idle') &&
|
||||
state?.phase === 'deployment' &&
|
||||
state?.mapBackgroundReady === true
|
||||
);
|
||||
},
|
||||
@@ -1536,7 +1536,7 @@ async function setupCumulativeBattle(page, battle, firstBattle, route) {
|
||||
return (
|
||||
state?.scene === 'BattleScene' &&
|
||||
state?.battleId === battleId &&
|
||||
(state?.phase === 'deployment' || state?.phase === 'idle') &&
|
||||
state?.phase === 'deployment' &&
|
||||
state?.mapBackgroundReady === true
|
||||
);
|
||||
},
|
||||
@@ -1649,7 +1649,9 @@ async function startDeploymentIfNeeded(page, battleId) {
|
||||
}, battleId);
|
||||
|
||||
if (state?.phase !== 'deployment') {
|
||||
return;
|
||||
throw new Error(
|
||||
`Expected ${battleId} to expose deployment before battle start: ${JSON.stringify(state)}`
|
||||
);
|
||||
}
|
||||
|
||||
await clickBattleDeploymentStart(page);
|
||||
@@ -1844,6 +1846,20 @@ async function enterCampaignCampFromBattleResult(page, battle, route) {
|
||||
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';
|
||||
if (settlement?.cta?.destination !== expectedDestination) {
|
||||
throw new Error(
|
||||
@@ -1915,6 +1931,21 @@ async function enterFinalCampFromBattleResult(page) {
|
||||
);
|
||||
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') {
|
||||
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-completion.mjs',
|
||||
'scripts/verify-campaign-presentation-profiles.mjs',
|
||||
'scripts/verify-campaign-objective-journal.mjs',
|
||||
'scripts/verify-growth-consistency.mjs',
|
||||
'scripts/verify-combat-presentation-settings.mjs',
|
||||
'scripts/verify-visual-motion-settings.mjs',
|
||||
|
||||
@@ -259,6 +259,10 @@ import {
|
||||
type CampaignVictoryRewardReport,
|
||||
type FirstBattleReport
|
||||
} from '../state/campaignState';
|
||||
import {
|
||||
resolveCampaignObjectiveJournal,
|
||||
type CampaignObjectiveJournalEntry
|
||||
} from '../state/campaignObjectiveJournal';
|
||||
import {
|
||||
chooseCityStayResonance,
|
||||
collectCityStayInformation,
|
||||
@@ -356,6 +360,14 @@ type CampTabButtonView = {
|
||||
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 VictoryRewardActionId = 'equipment' | 'supplies' | 'sortie' | 'close';
|
||||
@@ -11437,6 +11449,11 @@ export class CampScene extends Phaser.Scene {
|
||||
private campRosterPage = 0;
|
||||
private campRosterLayout?: CampRosterLayout;
|
||||
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 cityPanelBackground?: Phaser.GameObjects.Rectangle;
|
||||
private cityExploreButton?: Phaser.GameObjects.Rectangle;
|
||||
@@ -11593,6 +11610,11 @@ export class CampScene extends Phaser.Scene {
|
||||
this.navigationPending = false;
|
||||
this.visualMotionReduced = isVisualMotionReduced();
|
||||
this.activeTab = 'status';
|
||||
this.campaignJournalReturnTab = 'status';
|
||||
this.campaignJournalPanel = undefined;
|
||||
this.campaignJournalCardViews = [];
|
||||
this.campaignJournalVisibleText = [];
|
||||
this.campaignJournalVisibleChapterTitles = [];
|
||||
this.selectedCityLocation = 'information';
|
||||
this.campNoticeRemovalTimer = undefined;
|
||||
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-ENTER', (event: KeyboardEvent) => this.handleSortieEnterKey(event));
|
||||
this.input.keyboard?.on('keydown', (event: KeyboardEvent) => {
|
||||
if (!this.handleThirdCampPreparationKey(event)) {
|
||||
if (
|
||||
!this.handleCampaignJournalKey(event) &&
|
||||
!this.handleThirdCampPreparationKey(event)
|
||||
) {
|
||||
this.handleSaveSlotKey(event);
|
||||
}
|
||||
});
|
||||
@@ -13236,14 +13261,14 @@ export class CampScene extends Phaser.Scene {
|
||||
this.addTabButton('방문', 'visit', 758, 38, 60, 14);
|
||||
this.addTabButton('보급', 'supplies', 822, 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 {
|
||||
this.addTabButton('장수', 'status', 576, 38);
|
||||
this.addTabButton('대화', 'dialogue', 650, 38);
|
||||
this.addTabButton('방문', 'visit', 724, 38);
|
||||
this.addTabButton('보급', 'supplies', 798, 38);
|
||||
this.addTabButton('장비', 'equipment', 872, 38);
|
||||
this.addTabButton('연표', 'progress', 946, 38);
|
||||
this.addTabButton('기록 J', 'progress', 946, 38, 76, 14);
|
||||
}
|
||||
this.addCommandButton('저장', 1030, 38, 76, () => {
|
||||
soundDirector.playSelect();
|
||||
@@ -14118,6 +14143,38 @@ export class CampScene extends Phaser.Scene {
|
||||
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() {
|
||||
if (this.navigationPending) {
|
||||
return;
|
||||
@@ -14175,6 +14232,13 @@ export class CampScene extends Phaser.Scene {
|
||||
if (this.sortieObjects.length > 0) {
|
||||
soundDirector.playSelect();
|
||||
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 = () => {
|
||||
soundDirector.playSelect();
|
||||
if (tab === 'progress' && this.activeTab !== 'progress') {
|
||||
this.clearCampNotice();
|
||||
this.campaignJournalReturnTab = this.activeTab;
|
||||
}
|
||||
this.activeTab = tab;
|
||||
this.render();
|
||||
};
|
||||
@@ -23541,46 +23609,235 @@ export class CampScene extends Phaser.Scene {
|
||||
const width = 828;
|
||||
const height = 444;
|
||||
const progress = this.campaignTimelineProgress();
|
||||
const journal = resolveCampaignObjectiveJournal(
|
||||
this.campaign ?? getCampaignState()
|
||||
);
|
||||
const bg = this.track(this.add.rectangle(x, y, width, height, 0x101820, 0.9));
|
||||
bg.setOrigin(0);
|
||||
bg.setStrokeStyle(1, palette.gold, 0.56);
|
||||
this.campaignJournalPanel = bg;
|
||||
|
||||
this.track(this.add.text(x + 24, y + 22, '군영 연표', this.textStyle(24, '#f2e3bf', true)));
|
||||
this.track(this.add.text(x + 24, y + 56, '유비군의 장기 서사와 현재 위치를 확인합니다.', this.textStyle(14, '#d4dce6')));
|
||||
const title = this.track(this.add.text(x + 24, y + 18, '진군 기록', this.textStyle(24, '#f2e3bf', true)));
|
||||
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.renderProgressStatCard('완료 전장', `${progress.completedKnown}/${progress.totalKnown}`, x + 24, statY, 176);
|
||||
this.renderProgressStatCard('현재 장', progress.activeChapter.title, x + 212, statY, 198);
|
||||
this.renderProgressStatCard('최근 전장', progress.latestBattleTitle, x + 422, statY, 176);
|
||||
this.renderProgressStatCard('다음 전장', progress.nextBattleTitle, x + 610, statY, 190);
|
||||
this.campaignJournalVisibleText = [
|
||||
title.text,
|
||||
subtitle.text,
|
||||
progressLabel.text
|
||||
];
|
||||
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 listX = x + 24;
|
||||
const listY = y + 142;
|
||||
const compactChapterList = campaignTimelineChapters.length > 9;
|
||||
const chapterRowGap = compactChapterList ? 22 : 31;
|
||||
const chapterRowHeight = compactChapterList ? 20 : 27;
|
||||
this.track(this.add.text(listX, listY, '큰 흐름', this.textStyle(17, '#f2e3bf', true)));
|
||||
campaignTimelineChapters.forEach((chapter, index) => {
|
||||
const status = this.timelineChapterStatus(chapter, index, progress.activeChapterIndex, completedIds);
|
||||
const rowY = listY + 30 + index * chapterRowGap;
|
||||
const active = index === progress.activeChapterIndex;
|
||||
const complete = status === '완료';
|
||||
const row = this.track(this.add.rectangle(listX, rowY, 360, chapterRowHeight, active ? 0x25384a : complete ? 0x17231d : 0x151f2a, active ? 0.96 : 0.84));
|
||||
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';
|
||||
const textY = rowY + (compactChapterList ? 2 : 5);
|
||||
this.track(this.add.text(listX + 12, textY, status, this.textStyle(compactChapterList ? 10 : 11, markerColor, true)));
|
||||
this.track(this.add.text(listX + 64, rowY + (compactChapterList ? 1 : 4), chapter.title, this.textStyle(compactChapterList ? 11 : 13, active ? '#f2e3bf' : '#d4dce6', active)));
|
||||
const countText = chapter.battleIds.length > 0
|
||||
? `${chapter.battleIds.filter((id) => completedIds.has(id)).length}/${chapter.battleIds.length}`
|
||||
: '예정';
|
||||
const count = this.track(this.add.text(listX + 342, textY, countText, this.textStyle(compactChapterList ? 10 : 11, markerColor, true)));
|
||||
count.setOrigin(1, 0);
|
||||
});
|
||||
const journeyX = x + 24;
|
||||
const journeyY = y + 304;
|
||||
const journeyWidth = width - 48;
|
||||
const journeyHeight = 116;
|
||||
const journeyBackground = this.track(
|
||||
this.add.rectangle(
|
||||
journeyX,
|
||||
journeyY,
|
||||
journeyWidth,
|
||||
journeyHeight,
|
||||
0x0d141c,
|
||||
0.92
|
||||
)
|
||||
);
|
||||
journeyBackground.setOrigin(0);
|
||||
journeyBackground.setStrokeStyle(1, palette.blue, 0.42);
|
||||
const journeyTitle = this.track(this.add.text(
|
||||
journeyX + 16,
|
||||
journeyY + 12,
|
||||
'공개된 큰 흐름',
|
||||
this.textStyle(13, '#f2e3bf', true)
|
||||
));
|
||||
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) {
|
||||
@@ -25701,9 +25958,17 @@ export class CampScene extends Phaser.Scene {
|
||||
private showCampNotice(message: string) {
|
||||
this.clearCampNotice();
|
||||
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 noticeWidth = Math.min(760, Math.max(420, longestLineLength * 13));
|
||||
const text = this.scaleLegacyCampUi(this.add.text(campLegacyCanvasWidth / 2, 104, message, {
|
||||
const noticeWidth = Math.min(
|
||||
noticeMaximumWidth,
|
||||
Math.max(noticeMinimumWidth, longestLineLength * 13)
|
||||
);
|
||||
const text = this.scaleLegacyCampUi(this.add.text(noticeX, noticeY, message, {
|
||||
...this.textStyle(14, '#f2e3bf', true),
|
||||
align: 'center',
|
||||
wordWrap: { width: noticeWidth - 44, useAdvancedWrap: true }
|
||||
@@ -25712,7 +25977,9 @@ export class CampScene extends Phaser.Scene {
|
||||
text.setDepth(depth + 1);
|
||||
text.setLineSpacing(5);
|
||||
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.setDepth(depth);
|
||||
const noticeObjects: Phaser.GameObjects.GameObject[] = [box, text];
|
||||
@@ -26601,6 +26868,10 @@ export class CampScene extends Phaser.Scene {
|
||||
private clearContent() {
|
||||
this.contentObjects.forEach((object) => object.destroy());
|
||||
this.contentObjects = [];
|
||||
this.campaignJournalPanel = undefined;
|
||||
this.campaignJournalCardViews = [];
|
||||
this.campaignJournalVisibleText = [];
|
||||
this.campaignJournalVisibleChapterTitles = [];
|
||||
this.campDialogueRowButtons = {};
|
||||
this.campDialogueChoiceButtons = {};
|
||||
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() {
|
||||
const sortieChecklist = this.sortieChecklist();
|
||||
const sortieScenario = this.nextSortieScenario();
|
||||
@@ -28158,6 +28464,12 @@ export class CampScene extends Phaser.Scene {
|
||||
rosterCollection: this.rosterCollectionSummary(),
|
||||
reserveTrainingAwards: this.latestReserveTrainingAwards(),
|
||||
campaignProgress: this.campaignTimelineProgress(),
|
||||
campaignObjectiveJournal: {
|
||||
snapshot: resolveCampaignObjectiveJournal(
|
||||
this.campaign ?? getCampaignState()
|
||||
),
|
||||
view: this.campaignObjectiveJournalDebug()
|
||||
},
|
||||
campBattleId: this.currentCampBattleId(),
|
||||
campTitle: this.currentCampTitle(),
|
||||
campSoundscape: this.campSoundscape
|
||||
@@ -28234,6 +28546,14 @@ export class CampScene extends Phaser.Scene {
|
||||
sortieItemAssignments: this.campaign.sortieItemAssignments,
|
||||
sortieResonanceSelection: this.campaign.sortieResonanceSelection ?? null,
|
||||
activeSaveSlot: this.campaign.activeSaveSlot,
|
||||
acknowledgedVictoryRewardCategories: Object.fromEntries(
|
||||
Object.entries(
|
||||
this.campaign.acknowledgedVictoryRewardCategories
|
||||
).map(([battleId, categories]) => [
|
||||
battleId,
|
||||
[...(categories ?? [])]
|
||||
])
|
||||
),
|
||||
reserveTrainingAssignments: this.campaign.reserveTrainingAssignments,
|
||||
reserveTrainingFocus: this.campaign.reserveTrainingFocus,
|
||||
roster: this.campaign.roster.map((unit) => ({
|
||||
|
||||
@@ -50,6 +50,7 @@ import { ExplorationInputController } from '../input/ExplorationInputController'
|
||||
import { releaseTextureSource } from '../render/loaderLifecycle';
|
||||
import { isVisualMotionReduced } from '../settings/visualMotion';
|
||||
import { getCampaignState } from '../state/campaignState';
|
||||
import { resolveCampaignObjectiveJournal } from '../state/campaignObjectiveJournal';
|
||||
import { completeFirstPursuitScoutVisit } from '../state/firstPursuitScoutActions';
|
||||
import {
|
||||
completeSecondBattleReliefExploration,
|
||||
@@ -62,6 +63,7 @@ import {
|
||||
recordThirdCampExplorationActivity,
|
||||
thirdCampExplorationProgress
|
||||
} from '../state/thirdCampExplorationActions';
|
||||
import { CampaignObjectiveJournalOverlay } from '../ui/CampaignObjectiveJournalOverlay';
|
||||
import { fitDialogueFacePortrait } from '../ui/dialoguePortrait';
|
||||
import { startGameScene } from './lazyScenes';
|
||||
|
||||
@@ -351,6 +353,7 @@ export class CampVisitExplorationScene extends Phaser.Scene {
|
||||
private lastNotice = '';
|
||||
private ownedPresentationTextureKeys = new Set<string>();
|
||||
private visualMotionReduced = false;
|
||||
private campaignObjectiveJournal?: CampaignObjectiveJournalOverlay;
|
||||
|
||||
constructor() {
|
||||
super('CampVisitExplorationScene');
|
||||
@@ -400,6 +403,8 @@ export class CampVisitExplorationScene extends Phaser.Scene {
|
||||
this.explorationInput?.destroy();
|
||||
this.explorationInput = undefined;
|
||||
this.dialogueState = undefined;
|
||||
this.campaignObjectiveJournal?.destroy();
|
||||
this.campaignObjectiveJournal = undefined;
|
||||
this.closeChoicePanel();
|
||||
releaseExplorationCharacterTextureKeys(
|
||||
this,
|
||||
@@ -445,6 +450,7 @@ export class CampVisitExplorationScene extends Phaser.Scene {
|
||||
this.createDialoguePanel();
|
||||
this.ready = true;
|
||||
this.inputReadyAt = this.time.now + inputCarryoverGuardMs;
|
||||
this.createCampaignObjectiveJournal();
|
||||
this.refreshObjectiveHud();
|
||||
this.refreshActorMarkers();
|
||||
this.refreshInteractionPrompt();
|
||||
@@ -493,6 +499,11 @@ export class CampVisitExplorationScene extends Phaser.Scene {
|
||||
this.explorationInput?.discardInteractionRequest();
|
||||
return;
|
||||
}
|
||||
if (this.campaignObjectiveJournal?.isOpen()) {
|
||||
this.stopPlayerMovement();
|
||||
this.explorationInput?.discardInteractionRequest();
|
||||
return;
|
||||
}
|
||||
if (this.choicePanel) {
|
||||
this.stopPlayerMovement();
|
||||
this.explorationInput?.discardInteractionRequest();
|
||||
@@ -560,6 +571,10 @@ export class CampVisitExplorationScene extends Phaser.Scene {
|
||||
ready: this.ready,
|
||||
viewport: { width: this.scale.width, height: this.scale.height },
|
||||
campaignStep: campaign.step,
|
||||
campaignObjectiveJournal: {
|
||||
snapshot: resolveCampaignObjectiveJournal(campaign),
|
||||
view: this.campaignObjectiveJournal?.getDebugState() ?? null
|
||||
},
|
||||
background: {
|
||||
key: this.backgroundImage?.texture.key ?? this.currentBackgroundKey(),
|
||||
ready: this.backgroundImage?.active === true,
|
||||
@@ -1119,6 +1134,7 @@ export class CampVisitExplorationScene extends Phaser.Scene {
|
||||
this.actorViews.clear();
|
||||
this.blockers = [];
|
||||
this.dialogueState = undefined;
|
||||
this.campaignObjectiveJournal = undefined;
|
||||
this.choicePanel = undefined;
|
||||
this.choicePanelBounds = undefined;
|
||||
this.choiceViews = [];
|
||||
@@ -2291,7 +2307,16 @@ export class CampVisitExplorationScene extends Phaser.Scene {
|
||||
.bindKey(
|
||||
Phaser.Input.Keyboard.KeyCodes.TWO,
|
||||
() => 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(
|
||||
Phaser.Input.Events.POINTER_DOWN,
|
||||
@@ -2299,6 +2324,7 @@ export class CampVisitExplorationScene extends Phaser.Scene {
|
||||
if (
|
||||
!this.ready ||
|
||||
this.navigationPending ||
|
||||
this.campaignObjectiveJournal?.isOpen() ||
|
||||
this.time.now < this.inputReadyAt
|
||||
) {
|
||||
return;
|
||||
@@ -2334,6 +2360,10 @@ export class CampVisitExplorationScene extends Phaser.Scene {
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (this.campaignObjectiveJournal?.isOpen()) {
|
||||
this.campaignObjectiveJournal.close();
|
||||
return;
|
||||
}
|
||||
if (this.choicePanel) {
|
||||
this.closeChoicePanel();
|
||||
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) {
|
||||
if (!this.player) {
|
||||
return;
|
||||
@@ -3466,6 +3506,7 @@ export class CampVisitExplorationScene extends Phaser.Scene {
|
||||
if (
|
||||
!this.choicePanel ||
|
||||
this.navigationPending ||
|
||||
this.campaignObjectiveJournal?.isOpen() ||
|
||||
this.time.now < this.choiceReadyAt
|
||||
) {
|
||||
return;
|
||||
@@ -3477,6 +3518,13 @@ export class CampVisitExplorationScene extends Phaser.Scene {
|
||||
}
|
||||
|
||||
private chooseVisitChoice(choice: ChoiceOption) {
|
||||
if (
|
||||
!this.choicePanel ||
|
||||
this.navigationPending ||
|
||||
this.campaignObjectiveJournal?.isOpen()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (this.isThirdCampVisit()) {
|
||||
if ('rewardExp' in choice) {
|
||||
this.chooseThirdCampCompanion(choice);
|
||||
|
||||
@@ -53,6 +53,8 @@ import {
|
||||
setActiveCityStayId,
|
||||
type CampaignState
|
||||
} from '../state/campaignState';
|
||||
import { resolveCampaignObjectiveJournal } from '../state/campaignObjectiveJournal';
|
||||
import { CampaignObjectiveJournalOverlay } from '../ui/CampaignObjectiveJournalOverlay';
|
||||
import { fitDialogueFacePortrait } from '../ui/dialoguePortrait';
|
||||
import { startGameScene } from './lazyScenes';
|
||||
|
||||
@@ -209,6 +211,7 @@ export class CityStayScene extends Phaser.Scene {
|
||||
private lastNotice = '';
|
||||
private visualMotionReduced = false;
|
||||
private ownedPresentationTextureKeys = new Set<string>();
|
||||
private campaignObjectiveJournal?: CampaignObjectiveJournalOverlay;
|
||||
|
||||
constructor() {
|
||||
super('CityStayScene');
|
||||
@@ -267,6 +270,7 @@ export class CityStayScene extends Phaser.Scene {
|
||||
this.createDialoguePanel();
|
||||
this.ready = true;
|
||||
this.inputReadyAt = this.time.now + inputCarryoverGuardMs;
|
||||
this.createCampaignObjectiveJournal();
|
||||
this.refreshProgressHud();
|
||||
this.refreshActorMarkers();
|
||||
this.refreshInteractionPrompt();
|
||||
@@ -285,6 +289,8 @@ export class CityStayScene extends Phaser.Scene {
|
||||
this.explorationInput?.destroy();
|
||||
this.explorationInput = undefined;
|
||||
this.dialogueState = undefined;
|
||||
this.campaignObjectiveJournal?.destroy();
|
||||
this.campaignObjectiveJournal = undefined;
|
||||
releaseExplorationCharacterTextureKeys(
|
||||
this,
|
||||
this.characterTextureKeys()
|
||||
@@ -299,6 +305,12 @@ export class CityStayScene extends Phaser.Scene {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.campaignObjectiveJournal?.isOpen()) {
|
||||
this.stopPlayerMovement();
|
||||
this.explorationInput?.discardInteractionRequest();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.shopPanel || this.choicePanel) {
|
||||
this.stopPlayerMovement();
|
||||
this.explorationInput?.discardInteractionRequest();
|
||||
@@ -339,6 +351,10 @@ export class CityStayScene extends Phaser.Scene {
|
||||
ready: this.ready,
|
||||
cityStayId: this.cityStay.id,
|
||||
cityName: this.cityStay.city.name,
|
||||
campaignObjectiveJournal: {
|
||||
snapshot: resolveCampaignObjectiveJournal(campaign),
|
||||
view: this.campaignObjectiveJournal?.getDebugState() ?? null
|
||||
},
|
||||
visualMotionReduced: this.visualMotionReduced,
|
||||
viewport: { width: this.scale.width, height: this.scale.height },
|
||||
activeCityStayId: campaign.activeCityStayId ?? null,
|
||||
@@ -581,6 +597,7 @@ export class CityStayScene extends Phaser.Scene {
|
||||
this.dialoguePortraitFrame = undefined;
|
||||
this.dialoguePortrait = undefined;
|
||||
this.dialogueState = undefined;
|
||||
this.campaignObjectiveJournal = undefined;
|
||||
this.shopPanel = undefined;
|
||||
this.shopOfferViews = [];
|
||||
this.shopNotice = '';
|
||||
@@ -1298,10 +1315,24 @@ export class CityStayScene extends Phaser.Scene {
|
||||
.bindKey(
|
||||
Phaser.Input.Keyboard.KeyCodes.TWO,
|
||||
() => 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) => {
|
||||
if (!this.ready || this.navigationPending || this.time.now < this.inputReadyAt) {
|
||||
if (
|
||||
!this.ready ||
|
||||
this.navigationPending ||
|
||||
this.campaignObjectiveJournal?.isOpen() ||
|
||||
this.time.now < this.inputReadyAt
|
||||
) {
|
||||
return;
|
||||
}
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
if (this.campaignObjectiveJournal?.isOpen()) {
|
||||
this.campaignObjectiveJournal.close();
|
||||
return;
|
||||
}
|
||||
if (this.shopPanel) {
|
||||
this.closeShop();
|
||||
return;
|
||||
@@ -2135,7 +2170,11 @@ export class CityStayScene extends Phaser.Scene {
|
||||
}
|
||||
|
||||
private chooseDialogueByIndex(index: number) {
|
||||
if (!this.choicePanel || this.navigationPending) {
|
||||
if (
|
||||
!this.choicePanel ||
|
||||
this.navigationPending ||
|
||||
this.campaignObjectiveJournal?.isOpen()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const choice = this.cityStay.dialogue.choices[index];
|
||||
@@ -2145,6 +2184,13 @@ export class CityStayScene extends Phaser.Scene {
|
||||
}
|
||||
|
||||
private chooseDialogue(choice: CityResonanceDialogueChoice) {
|
||||
if (
|
||||
!this.choicePanel ||
|
||||
this.navigationPending ||
|
||||
this.campaignObjectiveJournal?.isOpen()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const result = chooseCityStayResonance(this.cityStay.id, choice.id);
|
||||
if (!result.ok) {
|
||||
this.closeChoicePanel();
|
||||
@@ -2905,7 +2951,22 @@ export class CityStayScene extends Phaser.Scene {
|
||||
}
|
||||
|
||||
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() {
|
||||
|
||||
@@ -41,8 +41,10 @@ import {
|
||||
setCampaignSortieOrderSelection,
|
||||
type CampaignTutorialId
|
||||
} from '../state/campaignState';
|
||||
import { resolveCampaignObjectiveJournal } from '../state/campaignObjectiveJournal';
|
||||
import { releaseTextureSource } from '../render/loaderLifecycle';
|
||||
import { isVisualMotionReduced } from '../settings/visualMotion';
|
||||
import { CampaignObjectiveJournalOverlay } from '../ui/CampaignObjectiveJournalOverlay';
|
||||
import { fitDialogueFacePortrait } from '../ui/dialoguePortrait';
|
||||
import { palette } from '../ui/palette';
|
||||
import { startGameScene } from './lazyScenes';
|
||||
@@ -228,6 +230,7 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
|
||||
private firstVisit = false;
|
||||
private visualMotionReduced = false;
|
||||
private ownedPresentationTextureKeys = new Set<string>();
|
||||
private campaignObjectiveJournal?: CampaignObjectiveJournalOverlay;
|
||||
|
||||
constructor() {
|
||||
super('PrologueMilitiaCampScene');
|
||||
@@ -276,6 +279,8 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
|
||||
this.navigationPending = false;
|
||||
this.clearNavigationTarget(true);
|
||||
this.dialogueState = undefined;
|
||||
this.campaignObjectiveJournal?.destroy();
|
||||
this.campaignObjectiveJournal = undefined;
|
||||
this.closeCommandChoice();
|
||||
this.stopNpcMovement();
|
||||
releaseExplorationCharacterTextureKeys(this, characterTextureKeys);
|
||||
@@ -290,6 +295,7 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
|
||||
this.loadingOverlay = undefined;
|
||||
this.ready = true;
|
||||
this.inputReadyAt = this.time.now + inputCarryoverGuardMs;
|
||||
this.createCampaignObjectiveJournal();
|
||||
this.refreshObjectiveHud();
|
||||
this.refreshNpcMarkers();
|
||||
this.refreshInteractionPrompt();
|
||||
@@ -313,6 +319,11 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
|
||||
this.interactionQueued = false;
|
||||
return;
|
||||
}
|
||||
if (this.campaignObjectiveJournal?.isOpen()) {
|
||||
this.stopPlayerMovement();
|
||||
this.interactionQueued = false;
|
||||
return;
|
||||
}
|
||||
if (this.commandChoicePanel) {
|
||||
this.stopPlayerMovement();
|
||||
this.interactionQueued = false;
|
||||
@@ -349,6 +360,10 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
|
||||
scene: this.scene.key,
|
||||
locationId: 'zhuo-volunteer-camp',
|
||||
ready: this.ready,
|
||||
campaignObjectiveJournal: {
|
||||
snapshot: resolveCampaignObjectiveJournal(campaign),
|
||||
view: this.campaignObjectiveJournal?.getDebugState() ?? null
|
||||
},
|
||||
visualMotionReduced: this.visualMotionReduced,
|
||||
viewport: { width: this.scale.width, height: this.scale.height },
|
||||
campaignStep: campaign.step,
|
||||
@@ -622,6 +637,7 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
|
||||
this.dialoguePortraitFrame = undefined;
|
||||
this.dialoguePortrait = undefined;
|
||||
this.dialogueState = undefined;
|
||||
this.campaignObjectiveJournal = undefined;
|
||||
this.commandChoicePanel = undefined;
|
||||
this.commandChoicePanelBounds = undefined;
|
||||
this.commandChoiceViews = [];
|
||||
@@ -1399,7 +1415,15 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
|
||||
keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.ENTER)
|
||||
.on('down', () => this.confirmFirstCommand());
|
||||
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) => {
|
||||
@@ -1407,6 +1431,7 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
|
||||
!this.ready ||
|
||||
this.navigationPending ||
|
||||
this.npcMovementPending ||
|
||||
this.campaignObjectiveJournal?.isOpen() ||
|
||||
this.time.now < this.inputReadyAt
|
||||
) {
|
||||
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) {
|
||||
if (!this.player) {
|
||||
return;
|
||||
@@ -2395,6 +2430,7 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
|
||||
if (
|
||||
!this.commandChoicePanel ||
|
||||
this.navigationPending ||
|
||||
this.campaignObjectiveJournal?.isOpen() ||
|
||||
(!bypassInputGuard && this.time.now < this.commandChoiceReadyAt)
|
||||
) {
|
||||
return false;
|
||||
@@ -2434,6 +2470,7 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
|
||||
!this.commandChoicePanel ||
|
||||
!this.pendingCommandOrderId ||
|
||||
this.navigationPending ||
|
||||
this.campaignObjectiveJournal?.isOpen() ||
|
||||
(!bypassInputGuard && this.time.now < this.commandChoiceReadyAt)
|
||||
) {
|
||||
return false;
|
||||
@@ -2461,6 +2498,7 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
|
||||
if (
|
||||
!this.commandChoicePanel ||
|
||||
this.navigationPending ||
|
||||
this.campaignObjectiveJournal?.isOpen() ||
|
||||
this.time.now < this.commandChoiceReadyAt
|
||||
) {
|
||||
return false;
|
||||
|
||||
@@ -35,8 +35,10 @@ import {
|
||||
prologueVillageCampaignTutorialIds,
|
||||
type CampaignTutorialId
|
||||
} from '../state/campaignState';
|
||||
import { resolveCampaignObjectiveJournal } from '../state/campaignObjectiveJournal';
|
||||
import { releaseTextureSource } from '../render/loaderLifecycle';
|
||||
import { isVisualMotionReduced } from '../settings/visualMotion';
|
||||
import { CampaignObjectiveJournalOverlay } from '../ui/CampaignObjectiveJournalOverlay';
|
||||
import { fitDialogueFacePortrait } from '../ui/dialoguePortrait';
|
||||
import { palette } from '../ui/palette';
|
||||
import { startGameScene } from './lazyScenes';
|
||||
@@ -247,6 +249,7 @@ export class PrologueVillageScene extends Phaser.Scene {
|
||||
private firstVisit = false;
|
||||
private visualMotionReduced = false;
|
||||
private ownedPresentationTextureKeys = new Set<string>();
|
||||
private campaignObjectiveJournal?: CampaignObjectiveJournalOverlay;
|
||||
|
||||
constructor() {
|
||||
super('PrologueVillageScene');
|
||||
@@ -297,6 +300,8 @@ export class PrologueVillageScene extends Phaser.Scene {
|
||||
this.clearNavigationTarget(true);
|
||||
this.queuedMovementIntent = undefined;
|
||||
this.dialogueState = undefined;
|
||||
this.campaignObjectiveJournal?.destroy();
|
||||
this.campaignObjectiveJournal = undefined;
|
||||
this.stopAllNpcMovement();
|
||||
releaseExplorationCharacterTextureKeys(this, characterTextureKeys);
|
||||
this.releasePresentationTextures();
|
||||
@@ -310,6 +315,7 @@ export class PrologueVillageScene extends Phaser.Scene {
|
||||
this.loadingOverlay = undefined;
|
||||
this.ready = true;
|
||||
this.inputReadyAt = this.time.now + inputCarryoverGuardMs;
|
||||
this.createCampaignObjectiveJournal();
|
||||
this.refreshObjectiveHud();
|
||||
this.refreshNpcMarkers();
|
||||
this.refreshInteractionPrompt();
|
||||
@@ -331,6 +337,12 @@ export class PrologueVillageScene extends Phaser.Scene {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.campaignObjectiveJournal?.isOpen()) {
|
||||
this.interactionQueued = false;
|
||||
this.stopPlayerMovement();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.oathGatherPending) {
|
||||
this.interactionQueued = false;
|
||||
this.stopPlayerMovement();
|
||||
@@ -377,6 +389,10 @@ export class PrologueVillageScene extends Phaser.Scene {
|
||||
visualMotionReduced: this.visualMotionReduced,
|
||||
viewport: { width: this.scale.width, height: this.scale.height },
|
||||
campaignStep: campaign.step,
|
||||
campaignObjectiveJournal: {
|
||||
snapshot: resolveCampaignObjectiveJournal(campaign),
|
||||
view: this.campaignObjectiveJournal?.getDebugState() ?? null
|
||||
},
|
||||
background: {
|
||||
key: this.backgroundImage?.texture.key ?? villageBackgroundKey,
|
||||
ready: this.backgroundImage?.active === true,
|
||||
@@ -577,6 +593,7 @@ export class PrologueVillageScene extends Phaser.Scene {
|
||||
this.dialoguePortraitFrame = undefined;
|
||||
this.dialoguePortrait = undefined;
|
||||
this.dialogueState = undefined;
|
||||
this.campaignObjectiveJournal = undefined;
|
||||
this.moveTarget = undefined;
|
||||
this.moveWaypoints = [];
|
||||
this.moveTargetNpcId = undefined;
|
||||
@@ -1187,6 +1204,14 @@ export class PrologueVillageScene extends Phaser.Scene {
|
||||
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([
|
||||
Phaser.Input.Keyboard.KeyCodes.UP,
|
||||
Phaser.Input.Keyboard.KeyCodes.DOWN,
|
||||
@@ -1201,6 +1226,7 @@ export class PrologueVillageScene extends Phaser.Scene {
|
||||
!this.ready ||
|
||||
this.navigationPending ||
|
||||
this.oathGatherPending ||
|
||||
this.campaignObjectiveJournal?.isOpen() ||
|
||||
this.time.now < this.inputReadyAt
|
||||
) {
|
||||
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) {
|
||||
if (!this.player) {
|
||||
return;
|
||||
|
||||
@@ -57,8 +57,10 @@ import {
|
||||
import { prologueOpeningPages } from '../data/prologueVillage';
|
||||
import { type PortraitKey, type StoryPage } from '../data/scenario';
|
||||
import { getCampaignState, getFirstBattleReport } from '../state/campaignState';
|
||||
import { resolveCampaignObjectiveJournal } from '../state/campaignObjectiveJournal';
|
||||
import { releaseTextureSource } from '../render/loaderLifecycle';
|
||||
import { isVisualMotionReduced } from '../settings/visualMotion';
|
||||
import { CampaignObjectiveJournalOverlay } from '../ui/CampaignObjectiveJournalOverlay';
|
||||
import { fitDialogueFacePortrait } from '../ui/dialoguePortrait';
|
||||
import { palette } from '../ui/palette';
|
||||
import { ensureLazyScene, startGameScene } from './lazyScenes';
|
||||
@@ -236,6 +238,7 @@ export class StoryScene extends Phaser.Scene {
|
||||
private storyEnvironmentHaze: StoryEnvironmentHazeView[] = [];
|
||||
private storyEnvironmentRandomState = 1;
|
||||
private storyEnvironmentRevision = 0;
|
||||
private campaignObjectiveJournal?: CampaignObjectiveJournalOverlay;
|
||||
|
||||
constructor() {
|
||||
super('StoryScene');
|
||||
@@ -290,6 +293,7 @@ export class StoryScene extends Phaser.Scene {
|
||||
this.storyEnvironmentHaze = [];
|
||||
this.storyEnvironmentRandomState = 1;
|
||||
this.storyEnvironmentRevision = 0;
|
||||
this.campaignObjectiveJournal = undefined;
|
||||
}
|
||||
|
||||
getDebugState() {
|
||||
@@ -309,6 +313,10 @@ export class StoryScene extends Phaser.Scene {
|
||||
return {
|
||||
scene: this.scene.key,
|
||||
ready: this.sys.isActive() && this.loadingText === undefined && this.background?.active === true,
|
||||
campaignObjectiveJournal: {
|
||||
snapshot: resolveCampaignObjectiveJournal(getCampaignState()),
|
||||
view: this.campaignObjectiveJournal?.getDebugState() ?? null
|
||||
},
|
||||
pageIndex: this.pageIndex,
|
||||
totalPages: this.pages.length,
|
||||
currentPageId: page?.id,
|
||||
@@ -426,6 +434,8 @@ export class StoryScene extends Phaser.Scene {
|
||||
this.activeStoryLoadErrorHandler = undefined;
|
||||
}
|
||||
this.pageLoadingText = undefined;
|
||||
this.campaignObjectiveJournal?.destroy();
|
||||
this.campaignObjectiveJournal = undefined;
|
||||
this.clearStoryEnvironment();
|
||||
this.releaseOwnedStoryTextures();
|
||||
});
|
||||
@@ -445,10 +455,33 @@ export class StoryScene extends Phaser.Scene {
|
||||
this.drawSceneShade(width, height);
|
||||
this.drawDialogPanel(width, height);
|
||||
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.keyboard?.on('keydown-SPACE', () => 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);
|
||||
}
|
||||
|
||||
@@ -2082,7 +2115,7 @@ export class StoryScene extends Phaser.Scene {
|
||||
}
|
||||
|
||||
private advance() {
|
||||
if (this.transitioning) {
|
||||
if (this.transitioning || this.campaignObjectiveJournal?.isOpen()) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
startNewCampaign,
|
||||
type CampaignSaveSlotSummary
|
||||
} from '../state/campaignState';
|
||||
import { resolveCampaignObjectiveJournal } from '../state/campaignObjectiveJournal';
|
||||
import { palette } from '../ui/palette';
|
||||
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}명 합류`);
|
||||
}
|
||||
|
||||
const progress = summarizeCampaignProgress(campaign);
|
||||
return this.compactSaveDetail(`${progress.meta} · ${progress.title}`);
|
||||
const journal = resolveCampaignObjectiveJournal(campaign);
|
||||
return this.compactSaveDetail(`◆ ${journal.current.title}`);
|
||||
}
|
||||
|
||||
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