feat: add spoiler-safe campaign objective journal

This commit is contained in:
2026-07-28 11:05:57 +09:00
parent a67cfbfbe3
commit 91607cfd4c
16 changed files with 5116 additions and 220 deletions

View File

@@ -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`와 이어하기 요약을 보존하고, 저장 슬롯에서
`방금 마친 일 → 지금 할 일`까지 충분히 읽을 수 있는 재개 흐름을 다듬는다.
## 장기 개선 순서

View File

@@ -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",

View File

@@ -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)}`);
}

File diff suppressed because it is too large Load Diff

View 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

View File

@@ -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',

View File

@@ -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) => ({

View File

@@ -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);

View File

@@ -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() {

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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) {

File diff suppressed because it is too large Load Diff

View 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
};
}