diff --git a/docs/game-design-improvement-goal.md b/docs/game-design-improvement-goal.md index 36adc04..14d8cc6 100644 --- a/docs/game-design-improvement-goal.md +++ b/docs/game-design-improvement-goal.md @@ -80,12 +80,24 @@ RPG로 다듬는다. 장애물을 우회해 대화 거리까지 이어지게 한다. - 내레이터 대사에는 빈 초상 틀을 남기지 않고 본문 폭을 넓힌다. -후속 묶음에서는 막사 준비가 화면과 전투에서 더 직접 보이게 한다. +### 완료한 2차 묶음: 막사 준비의 전투 회수 -- 관우의 정찰 완료: 작전판과 첫 전투 적 정보 변화 -- 장비의 대열 정비 완료: 흩어진 의병의 실제 집결 연출 -- 군수관 점검 완료: 보급 수레와 첫 전투 사용 물자 변화 -- 젊은 의병 격려: 첫 전투 중 유비의 말을 기억하는 짧은 반응 +- 관우의 정찰 완료: 작전판에 경로·적 표식이 남고, 첫 배치부터 적 의도와 + 예상 표적을 볼 수 있다. +- 장비의 대열 정비 완료: 훈련장에 의병 4명이 실제로 정렬되고, 첫 배치의 + 예비 시작칸이 기본 2곳에서 4곳으로 늘어난다. +- 군수관 점검 완료: 보급 수레에 물·상처약·천이 적재되고, 첫 전투에서 + 관우가 상처약 1개를 받는다. +- 첫 배치 패널에서 세 결과를 한 줄로 확인하며 새 모달은 추가하지 않는다. +- 막사 준비 요약 아래에 플레이어가 고른 첫 군령도 함께 남겨, 새 정보가 + 직전 선택의 의미를 덮지 않게 한다. +- 잘못된 배치 칸을 단순히 가리킬 때 뜨던 큰 경고는 없애고, 실제 클릭했을 + 때만 사이드 패널에서 제한을 설명한다. +- 필수 점검과 군령 반응의 중복 대사를 줄여 진행 입력을 약 7회 줄인다. + +다음 초반 묶음은 젊은 의병의 격려가 첫 전투 중 짧은 반응으로 돌아오고, +첫 승리 뒤 막사 인물들이 플레이어가 고른 군령과 전투 결과를 서로 다르게 +기억하도록 연결하는 것을 우선한다. ## 장기 개선 순서 diff --git a/scripts/verify-enemy-intent-rollout.mjs b/scripts/verify-enemy-intent-rollout.mjs index 45c9e59..b073fbb 100644 --- a/scripts/verify-enemy-intent-rollout.mjs +++ b/scripts/verify-enemy-intent-rollout.mjs @@ -168,6 +168,11 @@ async function verifyRuntimeIntentState(browserInstance, targetUrl, scenario, op { timeout: 30000 } ); } + await page.waitForFunction( + () => window.__HEROS_DEBUG__?.battle()?.triggeredBattleEvents?.includes('opening') === true, + undefined, + { timeout: 15000 } + ); const live = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); const aliveEnemyCount = live?.units?.filter((unit) => unit.faction === 'enemy' && unit.hp > 0).length ?? 0; diff --git a/scripts/verify-prologue-village-browser.mjs b/scripts/verify-prologue-village-browser.mjs index ce5f3d4..af08349 100644 --- a/scripts/verify-prologue-village-browser.mjs +++ b/scripts/verify-prologue-village-browser.mjs @@ -368,6 +368,18 @@ try { assert.equal(militiaCamp.npcs.length, 5); assert.equal(militiaCamp.objectives.length, 4); assert.equal(militiaCamp.completedObjectiveIds.length, 0); + assert.deepEqual( + militiaCamp.preparationFeedback.map(({ id, completed, worldCueVisible }) => ({ + id, + completed, + worldCueVisible + })), + [ + { id: 'review-scout-report', completed: false, worldCueVisible: false }, + { id: 'ready-vanguard', completed: false, worldCueVisible: false }, + { id: 'inspect-arms', completed: false, worldCueVisible: false } + ] + ); assert.equal(militiaCamp.exitUnlocked, false); assert.equal( militiaCamp.dialogue.active, @@ -440,6 +452,15 @@ try { await advanceMilitiaCampDialogueUntilClosed(page); militiaCamp = await readMilitiaCamp(page); assert(militiaCamp.completedObjectiveIds.includes('inspect-arms')); + const supplyFeedback = militiaCamp.preparationFeedback.find(({ id }) => id === 'inspect-arms'); + assert.equal(supplyFeedback?.worldCueVisible, true); + assertBoundsInsideCampMap(supplyFeedback?.worldCueBounds, 'completed supply world cue'); + assert( + militiaCamp.preparationFeedback + .filter(({ id }) => id !== 'inspect-arms') + .every(({ worldCueVisible }) => worldCueVisible === false), + 'Completing supplies must not reveal unrelated camp preparation cues.' + ); await teleportMilitiaCampTo(page, 'zou-jing'); await page.keyboard.press('e'); @@ -465,6 +486,11 @@ try { militiaCamp.completedObjectiveIds.includes('inspect-arms'), 'A completed militia camp inspection must survive reload and Continue.' ); + assert.equal( + militiaCamp.preparationFeedback.find(({ id }) => id === 'inspect-arms')?.worldCueVisible, + true, + 'A completed supply world cue must survive reload and Continue.' + ); assert.equal( militiaCamp.dialogue.active, false, @@ -478,6 +504,22 @@ try { [...militiaCamp.completedObjectiveIds].sort(), ['inspect-arms', 'ready-vanguard', 'review-scout-report'].sort() ); + assert( + militiaCamp.preparationFeedback.every(({ completed, worldCueVisible, worldCueBounds }) => { + if (!completed || !worldCueVisible) { + return false; + } + assertBoundsInsideCampMap(worldCueBounds, 'completed camp preparation cue'); + return true; + }), + 'Every completed preparation must remain visible in the camp world.' + ); + assert.equal( + militiaCamp.preparationFeedback.find(({ id }) => id === 'ready-vanguard')?.ambientActorCount, + 4, + 'Zhang Fei preparation should visibly line up four non-interactive volunteers.' + ); + assert.equal(militiaCamp.npcs.length, 5, 'Ambient formation actors must not become dialogue targets.'); assert.equal(militiaCamp.exitUnlocked, true); await captureStableScreenshot(page, 'dist/verification-prologue-militia-camp-ready.png'); @@ -575,7 +617,7 @@ try { )); const commandReaction = await readMilitiaCamp(page); assert.equal(commandReaction.commandChoice.open, false); - assert.equal(commandReaction.dialogue.totalLines, 3); + assert.equal(commandReaction.dialogue.totalLines, 2); assert.equal(commandReaction.dialogue.portraitTextureKey, 'portrait-zhang-fei-yellow-turban'); const savedCommand = await readCampaignSave(page); assert.deepEqual(savedCommand.sortieOrderSelection, { @@ -624,6 +666,89 @@ try { const firstBattle = await page.evaluate(() => window.__HEROS_DEBUG__?.battle?.()); assert.equal(firstBattle.sortieOrder.selectedId, 'mobile'); assert.equal(firstBattle.sortieOrder.live.orderId, 'mobile'); + assert.equal(firstBattle.firstBattlePreparation.eligible, true); + assert.equal(firstBattle.firstBattlePreparation.allComplete, true); + assert.equal(firstBattle.firstBattlePreparation.completedCount, 3); + assert.equal(firstBattle.firstBattlePreparation.summaryVisible, true); + assert.match( + firstBattle.battleHud.deploymentPanel.subtitleText, + /막사 준비 3\/3/, + 'The deployment subtitle should retain the completed camp preparation summary.' + ); + assert.match( + firstBattle.battleHud.deploymentPanel.subtitleText, + /첫 군령/, + 'The preparation summary must not replace the first command selected in camp.' + ); + assertBoundsInsideViewport( + firstBattle.firstBattlePreparation.summaryBounds, + 'first-battle preparation summary' + ); + assert.deepEqual( + firstBattle.firstBattlePreparation.callbacks.map(({ callback, completed }) => ({ + callback, + completed + })), + [ + { callback: 'enemy-intel', completed: true }, + { callback: 'deployment-flex', completed: true }, + { callback: 'field-salve', completed: true } + ] + ); + assert.equal(firstBattle.firstBattlePreparation.enemyIntel.visibleDuringDeployment, true); + assert(firstBattle.firstBattlePreparation.enemyIntel.forecastCount > 0); + assert(firstBattle.firstBattlePreparation.enemyIntel.visualCount > 0); + assert.deepEqual(firstBattle.firstBattlePreparation.deploymentFlex, { + enabled: true, + baseReserveSlotCount: 2, + bonusReserveSlotCount: 2, + activeReserveSlotCount: 4 + }); + assert.deepEqual(firstBattle.firstBattlePreparation.fieldSalve, { + enabled: true, + carrierUnitId: 'guan-yu', + itemId: 'salve', + count: 1 + }); + assert.equal(firstBattle.itemStocks['guan-yu'].salve, 1); + await captureStableScreenshot(page, 'dist/verification-first-battle-preparation.png'); + + await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); + scene?.saveBattleState?.(1); + scene?.loadBattleState?.(1); + }); + await page.waitForFunction( + () => window.__HEROS_DEBUG__?.battle?.()?.firstBattlePreparation?.fieldSalve?.count === 1, + undefined, + { timeout: 30000 } + ); + const resumedFirstBattle = await page.evaluate(() => window.__HEROS_DEBUG__?.battle?.()); + assert.equal( + resumedFirstBattle.firstBattlePreparation.fieldSalve.count, + 1, + 'Saving and resuming the first battle must not grant the preparation salve twice.' + ); + assert.equal(resumedFirstBattle.itemStocks['guan-yu'].salve, 1); + + await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); + scene?.scene.restart({ battleId: 'second-battle-yellow-turban-pursuit' }); + }); + await page.waitForFunction(() => { + const battle = window.__HEROS_DEBUG__?.battle?.(); + return ( + battle?.battleId === 'second-battle-yellow-turban-pursuit' && + battle?.mapBackgroundReady === true + ); + }, undefined, { timeout: 90000 }); + const secondBattle = await page.evaluate(() => window.__HEROS_DEBUG__?.battle?.()); + assert.equal(secondBattle.firstBattlePreparation.eligible, false); + assert.equal(secondBattle.firstBattlePreparation.completedCount, 0); + assert( + secondBattle.firstBattlePreparation.callbacks.every(({ completed }) => completed === false), + 'Militia-camp preparation callbacks must not leak into later battles.' + ); await seedLegacyCompletedVillageSave(page, battleSave); await page.reload({ waitUntil: 'domcontentloaded', timeout: 90000 }); @@ -924,6 +1049,17 @@ function assertBoundsInsideViewport(bounds, label) { ); } +function assertBoundsInsideCampMap(bounds, label) { + assert(bounds, `${label}: bounds are required`); + assert( + bounds.x >= 0 && + bounds.y >= 92 && + bounds.x + bounds.width <= 1488 && + bounds.y + bounds.height <= 958, + `${label}: expected bounds inside the militia-camp map, received ${JSON.stringify(bounds)}` + ); +} + function assertNpcTextureKeys(npcs, expectedTextureKeyByNpcId, sceneLabel) { assert(Array.isArray(npcs), `${sceneLabel}: expected an NPC debug-state array.`); const actualMappings = npcs @@ -975,7 +1111,7 @@ function assertExplorationBackground(background, expectedKey) { } async function captureStableScreenshot(page, path) { - await page.waitForTimeout(220); + await page.waitForTimeout(650); const loopSlept = await page.evaluate(() => { const loop = window.__HEROS_GAME__?.loop; if (!loop || typeof loop.sleep !== 'function') { diff --git a/scripts/verify-prologue-village-data.mjs b/scripts/verify-prologue-village-data.mjs index 0f37a54..48ac417 100644 --- a/scripts/verify-prologue-village-data.mjs +++ b/scripts/verify-prologue-village-data.mjs @@ -162,6 +162,24 @@ try { 'militia camp objective rows', failures ); + const preparationPayoffs = militiaCamp.prologueMilitiaCampPreparationPayoffs; + assertArrayEquals( + preparationPayoffs.map((payoff) => payoff.objectiveId), + expectedCampObjectiveIds, + 'militia camp preparation payoff order', + failures + ); + assertArrayEquals( + preparationPayoffs.map((payoff) => payoff.battleCallback), + ['enemy-intel', 'deployment-flex', 'field-salve'], + 'militia camp preparation battle callbacks', + failures + ); + assert( + preparationPayoffs.every((payoff) => payoff.worldCue.length > 0 && payoff.battleLabel.length > 0), + 'every militia camp preparation must name its world cue and first-battle callback', + failures + ); assert( campObjectives.find((objective) => objective.id === 'review-scout-report')?.label.includes('피난로'), 'Guan Yu scout objective must describe the evacuation-route information actually reviewed', @@ -214,7 +232,7 @@ try { failures ); assert( - choice.reaction.length === 3 && + choice.reaction.length === 2 && choice.reaction.some((line) => line.speaker === '관우') && choice.reaction.some((line) => line.speaker === '장비'), `first command ${choice.orderId} must receive distinct Guan Yu and Zhang Fei reactions`, @@ -232,6 +250,13 @@ try { 'Zou Jing command setup should be compressed to one line so the new decision adds no extra input', failures ); + assert( + ['guan-yu', 'zhang-fei', 'quartermaster'].every((npcId) => ( + campNpcs.find((npc) => npc.id === npcId)?.dialogue.length === 2 + )), + 'mandatory camp inspections should each resolve in two lines before their visible world payoff', + failures + ); assert( campNpcs.some((npc) => !npc.objectiveId && !npc.departure), 'militia camp should include at least one optional volunteer conversation', diff --git a/src/game/data/prologueMilitiaCamp.ts b/src/game/data/prologueMilitiaCamp.ts index b1892ee..a9dfe9f 100644 --- a/src/game/data/prologueMilitiaCamp.ts +++ b/src/game/data/prologueMilitiaCamp.ts @@ -12,6 +12,32 @@ export const prologueMilitiaCampRequiredObjectiveIds = [ export type PrologueMilitiaCampRequiredObjectiveId = (typeof prologueMilitiaCampRequiredObjectiveIds)[number]; +export const prologueMilitiaCampPreparationPayoffs = [ + { + objectiveId: 'review-scout-report', + worldCue: '작전판에 적 진형과 피난로 표식', + battleCallback: 'enemy-intel', + battleLabel: '배치 중 적 의도·예상 표적 공개' + }, + { + objectiveId: 'ready-vanguard', + worldCue: '훈련장 의병 대열 정렬', + battleCallback: 'deployment-flex', + battleLabel: '권장 전열과 예비 시작칸 2곳' + }, + { + objectiveId: 'inspect-arms', + worldCue: '보급 수레에 물·상처약 적재', + battleCallback: 'field-salve', + battleLabel: '관우에게 상처약 1개 지급' + } +] as const satisfies ReadonlyArray<{ + objectiveId: PrologueMilitiaCampRequiredObjectiveId; + worldCue: string; + battleCallback: 'enemy-intel' | 'deployment-flex' | 'field-salve'; + battleLabel: string; +}>; + export type PrologueMilitiaCampCommandChoice = { orderId: SortieOrderId; label: string; @@ -38,10 +64,6 @@ export const prologueMilitiaCampCommandChoices: readonly PrologueMilitiaCampComm speaker: '장비', textureKey: 'unit-zhang-fei', text: '좋소! 앞장서되 혼자 깊이 들지는 않겠소. 모두 함께 돌아오는 첫 승리를 만들겠소!' - }, - { - speaker: '군령', - text: '정예 군령이 기록되었다. 첫 전투에서 전원 생존과 높은 편성 평가를 달성하면 추가 전공을 얻는다.' } ] }, @@ -61,10 +83,6 @@ export const prologueMilitiaCampCommandChoices: readonly PrologueMilitiaCampComm speaker: '관우', textureKey: 'unit-guan-yu', text: '속도에 휩쓸려 대열이 갈라지지 않도록 중앙에서 장비의 돌파를 받치겠습니다.' - }, - { - speaker: '군령', - text: '기동 군령이 기록되었다. 첫 전투에서 제한 턴과 돌파 기여 조건을 달성하면 추가 전공을 얻는다.' } ] }, @@ -84,10 +102,6 @@ export const prologueMilitiaCampCommandChoices: readonly PrologueMilitiaCampComm speaker: '장비', textureKey: 'unit-zhang-fei', text: '좋소! 관우가 전열을 열면 내가 측면을 걷어 내 형님의 후원이 닿게 하겠소!' - }, - { - speaker: '군령', - text: '거점 군령이 기록되었다. 첫 전투에서 마을 목표와 전열·후원 기여를 달성하면 추가 전공을 얻는다.' } ] } @@ -159,12 +173,7 @@ export const prologueMilitiaCampNpcDefinitions: readonly PrologueMilitiaCampNpcD { speaker: '유비', textureKey: 'unit-liu-bei', - text: '피난민을 먼저 서쪽 샛길로 들이고 우물과 수레가 있는 길은 비워 둡시다. 정확한 적 진형은 출전 직전 작전판에 옮겨 주시오.' - }, - { - speaker: '관우', - textureKey: 'unit-guan-yu', - text: '보병과 궁병, 두령의 위치를 표식으로 정리하겠습니다. 저는 정찰대를 이끌어 마을 어귀를 계속 살피겠습니다.' + text: '피난민은 서쪽 샛길로 들이고 우물과 수레 길은 비워 둡시다. 보병과 궁병, 두령의 위치는 작전판에 옮겨 주시오.' } ], repeatDialogue: [ @@ -194,11 +203,6 @@ export const prologueMilitiaCampNpcDefinitions: readonly PrologueMilitiaCampNpcD speaker: '유비', textureKey: 'unit-liu-bei', text: '열 명씩 조를 나누고, 앞줄이 멈추면 뒷줄도 함께 멈추는 구령을 정합시다. 공을 다투느라 대열을 깨서는 안 되오.' - }, - { - speaker: '장비', - textureKey: 'unit-zhang-fei', - text: '좋소! 진군과 정지, 재집결 신호를 몸에 익히게 하겠소. 북문에 모일 때까지 누구도 제멋대로 달려나가지 못하게 하겠소!' } ], repeatDialogue: [ @@ -222,22 +226,12 @@ export const prologueMilitiaCampNpcDefinitions: readonly PrologueMilitiaCampNpcD { speaker: '의용군 군수관', textureKey: 'unit-shu-officer', - text: '중산 상인 장세평과 소쌍이 말과 쇠를 보탰습니다. 대장간에서는 유비 공의 자웅일대검, 관우 공의 청룡언월도, 장비 공의 장팔사모를 벼려 냈습니다.' - }, - { - speaker: '의용군 군수관', - textureKey: 'unit-shu-officer', - text: '장비 공이 장원 창고도 열어 주었습니다. 콩과 마른 곡식, 상처에 쓸 천까지 조마다 나누어 두었습니다.' + text: '장세평과 소쌍이 말과 쇠를 보태 자웅일대검·청룡언월도·장팔사모를 벼렸고, 장비 공이 연 창고의 콩·마른 곡식·상처에 쓸 천도 조마다 나누었습니다.' }, { speaker: '유비', textureKey: 'unit-liu-bei', - text: '무기보다 물과 퇴로부터 다시 확인해 주시오. 보급은 채울 수 있어도 사람의 목숨은 다시 채울 수 없소.' - }, - { - speaker: '의용군 군수관', - textureKey: 'unit-shu-officer', - text: '명심하겠습니다. 후송 수레는 남쪽 길에 세우고, 각 조에 상처약을 나누겠습니다.' + text: '무기보다 물과 퇴로가 먼저요. 후송 수레는 남쪽 길에 세우고 상처약을 각 조에 나누어, 누구도 홀로 남지 않게 해 주시오.' } ], repeatDialogue: [ @@ -261,7 +255,7 @@ export const prologueMilitiaCampNpcDefinitions: readonly PrologueMilitiaCampNpcD { speaker: '추정', textureKey: 'unit-shu-officer', - text: '정찰과 대열, 보급까지 갖추었군. 황건 두령 한석이 북동쪽에서 마을을 노린다. 그대들이 선봉을 맡되, 첫 승리를 어떤 군령으로 이끌지 직접 정하게.' + text: '막사가 잘 갖춰졌군. 황건 두령 한석이 북동쪽에서 마을을 노린다. 그대들이 선봉을 맡되, 첫 승리를 어떤 군령으로 이끌지 직접 정하게.' } ], lockedDialogue: [ diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index 161f6da..e5aee0e 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -30,6 +30,7 @@ import { } from '../data/campaignPresentationProfiles'; import { getSortieFlow } from '../data/campaignFlow'; import { findCityStayBeforeBattle } from '../data/cityStays'; +import { prologueMilitiaCampPreparationPayoffs } from '../data/prologueMilitiaCamp'; import { enemyIntentOpeningGuide, isEnemyIntentCounterplayAvailable, @@ -140,6 +141,7 @@ import { markCampaignStep, normalizeCampaignSortieRecommendationSnapshot, normalizeCampaignSortieItemAssignments, + prologueMilitiaCampCampaignTutorialIds, readCampaignSaveState, saveCampaignState, setFirstBattleReport, @@ -190,6 +192,20 @@ const battleEnvironmentHazeDepth = 3.12; const battleEnvironmentParticleDepth = 3.3; const firstBattleTutorialId: CampaignTutorialId = 'first-battle-basic-controls'; +type FirstBattlePreparationState = { + eligible: boolean; + scouting: boolean; + vanguard: boolean; + supplies: boolean; +}; + +const emptyFirstBattlePreparationState: FirstBattlePreparationState = { + eligible: false, + scouting: false, + vanguard: false, + supplies: false +}; + const unitTexture: Record = { 'liu-bei': 'unit-liu-bei', 'guan-yu': 'unit-guan-yu', @@ -1345,6 +1361,8 @@ type RosterPanelLayout = { type DeploymentPanelLayout = { headerBounds: { x: number; y: number; width: number; height: number }; statusBounds: { x: number; y: number; width: number; height: number }; + subtitleText: string; + preparationSummaryBounds: { x: number; y: number; width: number; height: number } | null; changeCount: number; recommended: boolean; noticeBounds: { x: number; y: number; width: number; height: number }; @@ -3859,6 +3877,7 @@ export class BattleScene extends Phaser.Scene { private launchSortieOrderId: SortieOrderId = 'elite'; private launchSortieResonanceBondId?: string; private launchSortieRecommendation?: CampaignSortieRecommendationSnapshot; + private firstBattlePreparation: FirstBattlePreparationState = { ...emptyFirstBattlePreparationState }; private coreResonancePursuitAttempts = 0; private coreResonancePursuitSuccesses = 0; private coreResonancePursuitFailures = 0; @@ -3962,6 +3981,7 @@ export class BattleScene extends Phaser.Scene { this.firstBattleTutorialCard = undefined; this.firstBattleTutorialSkipButton = undefined; const campaign = getCampaignState(); + this.firstBattlePreparation = this.resolveFirstBattlePreparation(campaign); const selectedOrderId = campaign.sortieOrderSelection?.battleId === battleScenario.id ? campaign.sortieOrderSelection.orderId : undefined; @@ -8372,6 +8392,51 @@ export class BattleScene extends Phaser.Scene { this.clearPointerFeedback(); } + private resolveFirstBattlePreparation(campaign: CampaignState): FirstBattlePreparationState { + if (battleScenario.id !== 'first-battle-zhuo-commandery') { + return { ...emptyFirstBattlePreparationState }; + } + const completed = new Set(campaign.completedTutorialIds); + return { + eligible: true, + scouting: completed.has(prologueMilitiaCampCampaignTutorialIds.reviewScoutReport), + vanguard: completed.has(prologueMilitiaCampCampaignTutorialIds.readyVanguard), + supplies: completed.has(prologueMilitiaCampCampaignTutorialIds.inspectArms) + }; + } + + private firstBattlePreparationCompletedCount() { + const preparation = this.firstBattlePreparation; + return [preparation.scouting, preparation.vanguard, preparation.supplies] + .filter(Boolean) + .length; + } + + private firstBattlePreparationSummary() { + if (!this.firstBattlePreparation.eligible) { + return undefined; + } + const effects = [ + this.firstBattlePreparation.scouting ? '적 의도 공개' : '', + this.firstBattlePreparation.vanguard ? '예비 배치 +2' : '', + this.firstBattlePreparation.supplies ? '관우 상처약 +1' : '' + ].filter(Boolean); + const count = this.firstBattlePreparationCompletedCount(); + return effects.length > 0 + ? `막사 준비 ${count}/3 반영 · ${effects.join(' · ')}` + : '막사 준비 미완료 · 기본 전열로 출전'; + } + + private firstBattleScoutBrief() { + if (!this.firstBattlePreparation.scouting) { + return undefined; + } + const enemies = battleUnits.filter((unit) => unit.faction === 'enemy' && unit.hp > 0); + const archers = enemies.filter((unit) => unit.classKey === 'archer').length; + const cavalry = enemies.filter((unit) => unit.classKey === 'cavalry').length; + return `관우 정찰: 적 ${enemies.length}부대 · 궁병 ${archers} · 기병 ${cavalry} · 두령은 북동쪽`; + } + private shouldShowPreBattleDeployment() { return Boolean(battleScenario.sortie?.deploymentSlots?.length) && this.deploymentEligibleUnits().length > 0; } @@ -8396,6 +8461,7 @@ export class BattleScene extends Phaser.Scene { this.centerCameraOnTile(center.x, center.y); this.renderDeploymentMap(); this.renderDeploymentPanel(); + this.refreshEnemyIntentForecast(); this.refreshUnitLegibilityStyles(); this.updatePointerFeedback(this.input.activePointer, true); } @@ -8410,6 +8476,10 @@ export class BattleScene extends Phaser.Scene { } private deploymentDefaultNotice() { + const scoutBrief = this.firstBattleScoutBrief(); + if (scoutBrief) { + return `${scoutBrief}. 적 표식을 보며 시작 위치를 조정하세요.`; + } return '추천 전열이 적용되어 있습니다. 장수를 선택한 뒤 시작 구역 안에서 위치를 바꿀 수 있습니다.'; } @@ -8423,6 +8493,16 @@ export class BattleScene extends Phaser.Scene { } private deploymentSubtitle() { + const preparationSummary = this.firstBattlePreparationSummary(); + if (preparationSummary && this.firstBattlePreparationCompletedCount() > 0) { + const strategySummary = this.launchSortieRecommendation + ? `선택 전략 · ${this.launchSortieRecommendation.label} · ${this.launchSortieRecommendation.summary}` + : (() => { + const order = sortieOrderDefinition(this.launchSortieOrderId); + return `첫 군령 · ${order.label} · ${order.summary}`; + })(); + return `${preparationSummary}\n${strategySummary}`; + } if (this.launchSortieRecommendation) { return `선택 전략 · ${this.launchSortieRecommendation.label} · ${this.launchSortieRecommendation.summary}`; } @@ -8549,9 +8629,13 @@ export class BattleScene extends Phaser.Scene { } } + const defaultReserveLimit = Math.max(2, Math.min(4, this.deploymentEligibleUnits().length + 1)); + const reserveLimit = this.firstBattlePreparation.eligible && !this.firstBattlePreparation.vanguard + ? Math.min(2, defaultReserveLimit) + : defaultReserveLimit; return candidates .sort((a, b) => Math.hypot(a.x - center.x, a.y - center.y) - Math.hypot(b.x - center.x, b.y - center.y)) - .slice(0, Math.max(2, Math.min(4, this.deploymentEligibleUnits().length + 1))); + .slice(0, reserveLimit); } private deploymentCameraCenterFromSlots(slots: DeploymentSlot[]) { @@ -8865,9 +8949,12 @@ export class BattleScene extends Phaser.Scene { } )); statusText.setOrigin(0.5); - this.trackSideObject(this.add.text(left + this.battleUiLength(12), top + this.battleUiLength(32), this.deploymentSubtitle(), { + const preparationSummaryVisible = + this.firstBattlePreparation.eligible && this.firstBattlePreparationCompletedCount() > 0; + const deploymentSubtitleText = this.deploymentSubtitle(); + const subtitleText = this.trackSideObject(this.add.text(left + this.battleUiLength(12), top + this.battleUiLength(32), deploymentSubtitleText, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: this.battleUiFontSize(10), + fontSize: this.battleUiFontSize(preparationSummaryVisible ? 9 : 10), color: '#c8d2dd', wordWrap: { width: width - this.battleUiLength(24), useAdvancedWrap: true }, maxLines: 2 @@ -8963,6 +9050,10 @@ export class BattleScene extends Phaser.Scene { this.deploymentPanelLayout = { headerBounds: { x: left, y: top, width, height: headerHeight }, statusBounds: { x: statusLeft, y: statusTop, width: statusWidth, height: statusHeight }, + subtitleText: deploymentSubtitleText, + preparationSummaryBounds: preparationSummaryVisible + ? this.gameObjectBoundsDebug(subtitleText) + : null, changeCount: deploymentChangeCount, recommended: recommendedDeployment, noticeBounds: { x: left, y: noticeTop, width, height: noticeHeight }, @@ -9145,6 +9236,7 @@ export class BattleScene extends Phaser.Scene { this.updateMiniMap(); this.renderDeploymentMap(); this.renderDeploymentPanel(); + this.refreshEnemyIntentForecast(); soundDirector.playSelect(); } @@ -9161,6 +9253,7 @@ export class BattleScene extends Phaser.Scene { this.updateMiniMap(); this.renderDeploymentMap(); this.renderDeploymentPanel(); + this.refreshEnemyIntentForecast(); soundDirector.playSelect(); } @@ -9182,6 +9275,7 @@ export class BattleScene extends Phaser.Scene { this.updateMiniMap(); this.renderDeploymentMap(); this.renderDeploymentPanel(); + this.refreshEnemyIntentForecast(); soundDirector.playSelect(); } @@ -9247,7 +9341,9 @@ export class BattleScene extends Phaser.Scene { return; } - this.showPointerFeedback(tile, 0x9b4d45, '표시된 시작 구역 안에서만 배치할 수 있습니다.', key, 0x9b4d45, 0.08, 0.66); + // Most of the map is intentionally outside the small deployment zone. + // Keep ordinary hovering quiet; an attempted click already explains the restriction in the side panel. + this.clearPointerFeedback(); } private showEnemyThreatRange(transitionTrigger: SidePanelTransitionTrigger = 'system') { @@ -9451,6 +9547,14 @@ export class BattleScene extends Phaser.Scene { } private enemyIntentForecastEnabled() { + if ( + this.phase === 'deployment' && + this.firstBattlePreparation.eligible && + this.firstBattlePreparation.scouting && + !this.battleOutcome + ) { + return true; + } return isEnemyIntentForecastAvailable({ activeFaction: this.activeFaction, phase: this.phase, @@ -17027,7 +17131,7 @@ export class BattleScene extends Phaser.Scene { private createItemStocks(campaign?: CampaignState) { const sortieItemAssignments = this.effectiveSortieItemAssignments(campaign); const usesSortieAssignments = Object.keys(sortieItemAssignments).length > 0; - return new Map( + const stocksByUnit = new Map( battleUnits.filter((unit) => unit.faction === 'ally').map((unit) => { const stocks = unit.faction === 'ally' && usesSortieAssignments ? sortieItemAssignments[unit.id] ?? {} @@ -17038,6 +17142,13 @@ export class BattleScene extends Phaser.Scene { ] as const; }) ); + if (this.firstBattlePreparation.eligible && this.firstBattlePreparation.supplies) { + const guanYuStocks = stocksByUnit.get('guan-yu'); + if (guanYuStocks) { + guanYuStocks.set('salve', Math.min(2, (guanYuStocks.get('salve') ?? 0) + 1)); + } + } + return stocksByUnit; } private createEnemyHomeTiles() { @@ -28484,6 +28595,10 @@ export class BattleScene extends Phaser.Scene { recommended: this.deploymentPanelLayout.recommended, headerBounds: { ...this.deploymentPanelLayout.headerBounds }, statusBounds: { ...this.deploymentPanelLayout.statusBounds }, + subtitleText: this.deploymentPanelLayout.subtitleText, + preparationSummaryBounds: this.deploymentPanelLayout.preparationSummaryBounds + ? { ...this.deploymentPanelLayout.preparationSummaryBounds } + : null, noticeBounds: { ...this.deploymentPanelLayout.noticeBounds }, noticeTextBounds: { ...this.deploymentPanelLayout.noticeTextBounds }, roleBounds: this.deploymentPanelLayout.roleBounds.map((bounds) => ({ ...bounds })), @@ -28620,6 +28735,61 @@ export class BattleScene extends Phaser.Scene { }; } + private firstBattlePreparationDebugState() { + const preparation = this.firstBattlePreparation; + const completedByCallback = { + 'enemy-intel': preparation.scouting, + 'deployment-flex': preparation.vanguard, + 'field-salve': preparation.supplies + } as const; + const reserveSlotCount = preparation.eligible + ? this.deploymentSlots().filter((slot) => !slot.unitId).length + : 0; + return { + eligible: preparation.eligible, + completedCount: this.firstBattlePreparationCompletedCount(), + allComplete: + preparation.eligible && + preparation.scouting && + preparation.vanguard && + preparation.supplies, + summary: this.firstBattlePreparationSummary() ?? null, + summaryVisible: + this.phase === 'deployment' && + Boolean(this.deploymentPanelLayout?.preparationSummaryBounds), + summaryBounds: this.deploymentPanelLayout?.preparationSummaryBounds + ? { ...this.deploymentPanelLayout.preparationSummaryBounds } + : null, + callbacks: prologueMilitiaCampPreparationPayoffs.map((payoff) => ({ + id: payoff.objectiveId, + callback: payoff.battleCallback, + label: payoff.battleLabel, + completed: completedByCallback[payoff.battleCallback] + })), + enemyIntel: { + enabled: preparation.scouting, + visibleDuringDeployment: + this.phase === 'deployment' && + preparation.scouting && + this.enemyIntentForecastEnabled(), + forecastCount: this.enemyIntentForecasts.size, + visualCount: this.enemyIntentVisuals.length + }, + deploymentFlex: { + enabled: preparation.vanguard, + baseReserveSlotCount: preparation.eligible ? 2 : 0, + bonusReserveSlotCount: preparation.vanguard ? 2 : 0, + activeReserveSlotCount: reserveSlotCount + }, + fieldSalve: { + enabled: preparation.supplies, + carrierUnitId: 'guan-yu', + itemId: 'salve', + count: this.itemStocks.get('guan-yu')?.get('salve') ?? 0 + } + }; + } + private firstBattleTutorialDebugState() { const path = this.firstBattleTutorialPath; return { @@ -28743,6 +28913,7 @@ export class BattleScene extends Phaser.Scene { ? cloneCampaignSortieRecommendationSnapshot(this.launchSortieRecommendation) : null, battleHud: this.battleHudDebugState(), + firstBattlePreparation: this.firstBattlePreparationDebugState(), firstBattleTutorial: this.firstBattleTutorialDebugState(), combatCutIn: combatCutInDebug, combatCutInStage: combatCutInDebug, diff --git a/src/game/scenes/PrologueMilitiaCampScene.ts b/src/game/scenes/PrologueMilitiaCampScene.ts index 6f501db..6b75bdb 100644 --- a/src/game/scenes/PrologueMilitiaCampScene.ts +++ b/src/game/scenes/PrologueMilitiaCampScene.ts @@ -10,6 +10,7 @@ import { prologueMilitiaCampCommandChoices, prologueMilitiaCampNpcDefinitions, prologueMilitiaCampObjectiveDefinitions, + prologueMilitiaCampPreparationPayoffs, prologueMilitiaCampRequiredObjectiveIds, type PrologueMilitiaCampCommandChoice, type PrologueMilitiaCampNpcDefinition, @@ -135,6 +136,11 @@ type CommandChoiceView = { selectLabel: Phaser.GameObjects.Text; }; +type PreparationFeedbackView = { + container: Phaser.GameObjects.Container; + actorSprites: Phaser.GameObjects.Sprite[]; +}; + const objectiveTutorialIds: Record = { 'review-scout-report': prologueMilitiaCampCampaignTutorialIds.reviewScoutReport, 'ready-vanguard': prologueMilitiaCampCampaignTutorialIds.readyVanguard, @@ -159,6 +165,7 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { private playerDirection: ExplorationCharacterDirection = 'north'; private playerMoving = false; private npcViews = new Map(); + private preparationFeedbackViews = new Map(); private npcMoveTweens = new Map(); private npcMovementPending = false; private blockers: Phaser.Geom.Rectangle[] = []; @@ -255,6 +262,7 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { ensureExplorationCharacterAnimations(this, characterTextureKeys); this.createActors(); + this.createPreparationFeedback(); this.createDialoguePanel(); this.loadingOverlay?.destroy(); this.loadingOverlay = undefined; @@ -269,7 +277,7 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { { speaker: '유비', textureKey: 'unit-liu-bei', - text: '맹세만으로 백성을 지킬 수는 없다. 막사를 직접 돌며 관우의 정찰, 장비의 의병 대열, 군수관의 병기와 후송 준비를 확인하자.' + text: '맹세만으로 백성을 지킬 수는 없다. 막사를 직접 돌며 모두가 살아 돌아올 준비를 갖추자.' } ]); } @@ -380,6 +388,26 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { unlocked: objective.id !== 'receive-command' || this.allRequiredObjectivesComplete() })), completedObjectiveIds: this.completedRequiredObjectiveIds(), + preparationFeedback: prologueMilitiaCampPreparationPayoffs.map((payoff) => { + const view = this.preparationFeedbackViews.get(payoff.objectiveId); + return { + id: payoff.objectiveId, + completed: this.isObjectiveComplete(payoff.objectiveId), + worldCue: payoff.worldCue, + battleCallback: payoff.battleCallback, + battleLabel: payoff.battleLabel, + worldCueVisible: view?.container.visible ?? false, + worldCueBounds: view ? this.boundsSnapshot(view.container.getBounds()) : null, + ambientActorCount: view?.actorSprites.length ?? 0, + ambientActors: view?.actorSprites.map((sprite) => ({ + x: sprite.x, + y: sprite.y, + textureKey: sprite.texture.key, + animationKey: sprite.anims.currentAnim?.key ?? null, + bounds: this.boundsSnapshot(sprite.getBounds()) + })) ?? [] + }; + }), exitUnlocked: this.allRequiredObjectivesComplete(), npcs: Array.from(this.npcViews.values()).map(({ definition, sprite }) => ({ id: definition.id, @@ -499,6 +527,7 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { prologueMilitiaCampRequiredObjectiveIds.forEach((objectiveId) => { completeCampaignTutorial(objectiveTutorialIds[objectiveId]); }); + this.syncPreparationFeedback(); this.refreshObjectiveHud(); this.refreshNpcMarkers(); return this.completedRequiredObjectiveIds(); @@ -532,6 +561,7 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { this.playerDirection = 'north'; this.playerMoving = false; this.npcViews.clear(); + this.preparationFeedbackViews.clear(); this.npcMoveTweens.clear(); this.npcMovementPending = false; this.blockers = []; @@ -835,6 +865,190 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { }).setOrigin(0.5).setDepth(45); } + private createPreparationFeedback() { + this.preparationFeedbackViews.set( + 'review-scout-report', + this.createScoutPreparationFeedback() + ); + this.preparationFeedbackViews.set( + 'ready-vanguard', + this.createVanguardPreparationFeedback() + ); + this.preparationFeedbackViews.set( + 'inspect-arms', + this.createSupplyPreparationFeedback() + ); + this.syncPreparationFeedback(); + } + + private createScoutPreparationFeedback(): PreparationFeedbackView { + const graphics = this.add.graphics(); + graphics.fillStyle(0xe9d6a1, 0.18); + graphics.fillRoundedRect(302, 386, 106, 50, 4); + graphics.lineStyle(3, 0xc49a55, 0.95); + graphics.beginPath(); + graphics.moveTo(314, 425); + graphics.lineTo(330, 413); + graphics.lineTo(347, 418); + graphics.lineTo(363, 399); + graphics.lineTo(389, 392); + graphics.strokePath(); + graphics.lineStyle(2, 0x87a478, 0.92); + graphics.beginPath(); + graphics.moveTo(313, 429); + graphics.lineTo(340, 432); + graphics.lineTo(376, 423); + graphics.lineTo(397, 432); + graphics.strokePath(); + [ + [330, 413, 0xd8b15f], + [363, 399, 0xbf5b4b], + [389, 392, 0xbf5b4b] + ].forEach(([x, y, tone]) => { + graphics.fillStyle(tone, 0.98); + graphics.fillCircle(x, y, 5); + graphics.lineStyle(1, 0x211812, 0.9); + graphics.strokeCircle(x, y, 5); + }); + + const stamp = this.add.text(400, 440, '정찰도', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '12px', + color: '#f4ddb0', + fontStyle: 'bold', + backgroundColor: '#5b2b22d9', + padding: { left: 6, right: 6, top: 2, bottom: 2 } + }).setOrigin(1, 1); + const container = this.add.container(0, 0, [graphics, stamp]) + .setDepth(410) + .setVisible(false); + return { + container, + actorSprites: [] + }; + } + + private createVanguardPreparationFeedback(): PreparationFeedbackView { + const graphics = this.add.graphics(); + const formationXs = [985, 1065, 1200, 1280]; + graphics.lineStyle(3, 0xc6a46a, 0.54); + graphics.lineBetween(948, 543, 1318, 543); + formationXs.forEach((x) => { + graphics.lineStyle(2, 0xdec28b, 0.42); + graphics.strokeEllipse(x, 527, 70, 30); + graphics.fillStyle(0x251c12, 0.22); + graphics.fillEllipse(x, 527, 62, 24); + }); + + const actors: Phaser.GameObjects.Sprite[] = []; + const objects: Phaser.GameObjects.GameObject[] = [graphics]; + formationXs.forEach((x, index) => { + const shadow = this.add.ellipse(x, 523, 52, 18, 0x07100a, 0.42); + const sprite = this.createCharacterSprite( + explorationCharacterTextureKeyByUnitTextureKey['unit-shu-infantry'], + x, + 505, + 78 - index, + 'north' + ); + sprite.setData('preparationActor', true); + actors.push(sprite); + objects.push(shadow, sprite); + }); + const container = this.add.container(0, 0, objects) + .setDepth(610) + .setVisible(false); + return { + container, + actorSprites: actors + }; + } + + private createSupplyPreparationFeedback(): PreparationFeedbackView { + const graphics = this.add.graphics(); + graphics.fillStyle(0x5e3925, 0.92); + graphics.fillRoundedRect(400, 687, 94, 54, 8); + graphics.lineStyle(4, 0xb9814e, 0.95); + graphics.lineBetween(404, 713, 490, 713); + graphics.lineBetween(447, 690, 447, 738); + graphics.fillStyle(0x314d58, 0.95); + graphics.fillCircle(410, 682, 17); + graphics.fillRect(403, 678, 14, 22); + graphics.fillStyle(0x8b6a48, 1); + graphics.fillCircle(481, 680, 16); + graphics.fillStyle(0xddd0b0, 0.92); + graphics.fillRoundedRect(432, 669, 31, 24, 7); + graphics.lineStyle(2, 0x9a3f36, 0.9); + graphics.lineBetween(435, 681, 460, 681); + graphics.lineBetween(447, 672, 447, 690); + + const tags = [ + this.add.text(410, 682, '물', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '11px', + color: '#eaf7fb', + fontStyle: 'bold' + }).setOrigin(0.5), + this.add.text(447, 681, '약', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '11px', + color: '#4a201c', + fontStyle: 'bold' + }).setOrigin(0.5), + this.add.text(481, 680, '천', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '11px', + color: '#f1e1bf', + fontStyle: 'bold' + }).setOrigin(0.5), + this.add.text(447, 748, '후송 물자 적재', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '13px', + color: '#f0dfbd', + fontStyle: 'bold', + backgroundColor: '#1b1712d4', + padding: { left: 8, right: 8, top: 3, bottom: 3 } + }).setOrigin(0.5) + ]; + const container = this.add.container(0, 0, [graphics, ...tags]) + .setDepth(850) + .setVisible(false); + return { + container, + actorSprites: [] + }; + } + + private syncPreparationFeedback( + animatedObjectiveId?: PrologueMilitiaCampRequiredObjectiveId + ) { + prologueMilitiaCampRequiredObjectiveIds.forEach((objectiveId) => { + const view = this.preparationFeedbackViews.get(objectiveId); + if (!view) { + return; + } + const completed = this.isObjectiveComplete(objectiveId); + if (!completed) { + this.tweens.killTweensOf(view.container); + view.container.setVisible(false).setAlpha(0).setY(0); + return; + } + if (animatedObjectiveId !== objectiveId || view.container.visible) { + view.container.setVisible(true).setAlpha(1).setY(0); + return; + } + this.tweens.killTweensOf(view.container); + view.container.setVisible(true).setAlpha(0).setY(12); + this.tweens.add({ + targets: view.container, + alpha: 1, + y: 0, + duration: 520, + ease: 'Back.easeOut' + }); + }); + } + private addBlocker(x: number, y: number, width: number, height: number) { this.blockers.push(new Phaser.Geom.Rectangle(x, y, width, height)); } @@ -1426,6 +1640,7 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { return; } completeCampaignTutorial(objectiveTutorialIds[objectiveId]); + this.syncPreparationFeedback(objectiveId); soundDirector.playObjectiveAchieved(); this.refreshObjectiveHud(); this.refreshNpcMarkers();