diff --git a/package.json b/package.json index bc9b41f..00772fd 100644 --- a/package.json +++ b/package.json @@ -21,8 +21,11 @@ "verify:battle-data": "node scripts/verify-battle-scenario-data.mjs", "verify:campaign-data": "node scripts/verify-campaign-flow-data.mjs", "verify:campaign-save": "node scripts/verify-campaign-save-normalization.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", + "verify:combat-presentation": "node scripts/verify-combat-presentation-settings.mjs", + "verify:enemy-intent": "node scripts/verify-enemy-intent-rollout.mjs", "verify:sortie-synergy": "node scripts/verify-sortie-synergy.mjs", "verify:sortie-recommendations": "node scripts/verify-sortie-recommendations.mjs", "verify:camp-rewards": "node scripts/verify-camp-reward-data.mjs", @@ -43,7 +46,7 @@ "verify:flow": "node scripts/verify-flow.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 && tsc --noEmit && pnpm run build && pnpm run verify:release && pnpm run verify:performance", + "verify:local-release": "pnpm run verify:static-data && pnpm run verify:enemy-intent && tsc --noEmit && pnpm run build && pnpm run verify:release && pnpm run verify:performance", "verify:public-deploy": "node scripts/verify-public-deploy.mjs", "qa:representative": "node scripts/qa-representative-battles.mjs", "qa:battle-maps:webgl": "node scripts/qa-representative-battles.mjs --set=campaign --battles=1,9,18,22,33,40,46,54,59,61,64,66 --renderer=webgl --report=dist/qa-battle-maps-webgl.json", diff --git a/scripts/verify-combat-presentation-settings.mjs b/scripts/verify-combat-presentation-settings.mjs new file mode 100644 index 0000000..41f9933 --- /dev/null +++ b/scripts/verify-combat-presentation-settings.mjs @@ -0,0 +1,58 @@ +import { createServer } from 'vite'; + +const server = await createServer({ + logLevel: 'error', + server: { middlewareMode: true, hmr: false }, + appType: 'custom' +}); + +function assert(condition, message) { + if (!condition) { + throw new Error(message); + } +} + +try { + const presentation = await server.ssrLoadModule('/src/game/settings/combatPresentation.ts'); + const values = new Map(); + const storage = { + getItem: (key) => values.get(key) ?? null, + setItem: (key, value) => values.set(key, value) + }; + + assert(presentation.loadCombatPresentationMode(storage) === 'important', 'The default mode must show only important cut-ins.'); + assert( + presentation.nextCombatPresentationMode('always') === 'important' && + presentation.nextCombatPresentationMode('important') === 'map' && + presentation.nextCombatPresentationMode('map') === 'always', + 'The three presentation modes must cycle in the displayed order.' + ); + + for (const mode of presentation.combatPresentationModes) { + presentation.saveCombatPresentationMode(mode, storage); + assert(presentation.loadCombatPresentationMode(storage) === mode, `Mode ${mode} did not persist.`); + } + + values.set(presentation.combatPresentationStorageKey, 'invalid'); + assert(presentation.loadCombatPresentationMode(storage) === 'important', 'Invalid stored values must normalize to the default.'); + + const normal = {}; + const critical = { critical: true }; + const defeated = { defeated: true }; + const signature = { signature: true }; + const resonance = { resonance: true }; + const growth = { growth: true }; + + assert(presentation.shouldUseFullCombatCutIn('always', normal), 'Always mode must promote normal attacks.'); + assert(!presentation.shouldUseFullCombatCutIn('important', normal), 'Important mode must keep normal attacks on the map.'); + assert(presentation.shouldUseFullCombatCutIn('important', critical), 'Critical hits must be promoted.'); + assert(presentation.shouldUseFullCombatCutIn('important', defeated), 'Defeats must be promoted.'); + assert(presentation.shouldUseFullCombatCutIn('important', signature), 'Signature tactics must be promoted.'); + assert(presentation.shouldUseFullCombatCutIn('important', resonance), 'Resonance actions must be promoted.'); + assert(presentation.shouldUseFullCombatCutIn('important', growth), 'Level-up actions must be promoted.'); + assert(!presentation.shouldUseFullCombatCutIn('map', signature), 'Map mode must suppress even signature cut-ins.'); + + console.info('Verified combat presentation settings, persistence, cycling, and importance promotion.'); +} finally { + await server.close(); +} diff --git a/scripts/verify-enemy-intent-rollout.mjs b/scripts/verify-enemy-intent-rollout.mjs new file mode 100644 index 0000000..45c9e59 --- /dev/null +++ b/scripts/verify-enemy-intent-rollout.mjs @@ -0,0 +1,324 @@ +import assert from 'node:assert/strict'; +import { createServer } from 'vite'; +import { chromium } from 'playwright'; +import { desktopBrowserContextOptions, desktopBrowserViewport } from './desktop-browser-viewport.mjs'; + +const server = await createServer({ + logLevel: 'error', + server: { host: '127.0.0.1', port: 0, hmr: false }, + appType: 'spa' +}); +let browser; + +try { + await server.listen(); + const { battleScenarios } = await server.ssrLoadModule('/src/game/data/battles.ts'); + const { + enemyIntentOpeningGuide, + isEnemyIntentCounterplayAvailable, + isEnemyIntentForecastAvailable + } = await server.ssrLoadModule('/src/game/data/enemyIntent.ts'); + const scenarios = Object.values(battleScenarios); + assert.equal(scenarios.length, 66, 'Enemy-intent rollout must cover the complete 66-battle campaign.'); + + const playablePhases = ['idle', 'moving', 'command', 'targeting', 'animating']; + scenarios.forEach((scenario) => { + assert.ok(scenario.sortie, `${scenario.id}: sortie roles are required for counterplay contribution reporting.`); + assert.ok(scenario.units.some((unit) => unit.faction === 'ally'), `${scenario.id}: missing allied intent target.`); + assert.ok(scenario.units.some((unit) => unit.faction === 'enemy'), `${scenario.id}: missing enemy intent source.`); + + playablePhases.forEach((phase) => { + assert.equal( + isEnemyIntentForecastAvailable({ activeFaction: 'ally', phase, battleResolved: false }), + true, + `${scenario.id}: intent forecast unexpectedly disabled during ${phase}.` + ); + assert.equal( + isEnemyIntentCounterplayAvailable({ phase, battleResolved: false }), + true, + `${scenario.id}: counterplay unexpectedly disabled during ${phase}.` + ); + }); + }); + + assert.equal( + isEnemyIntentForecastAvailable({ activeFaction: 'ally', phase: 'deployment', battleResolved: false }), + false, + 'Deployment must not show forecasts before positions are confirmed.' + ); + assert.equal( + isEnemyIntentForecastAvailable({ activeFaction: 'enemy', phase: 'animating', battleResolved: false }), + false, + 'Enemy execution must hide stale forecast visuals.' + ); + assert.equal( + isEnemyIntentForecastAvailable({ activeFaction: 'ally', phase: 'resolved', battleResolved: true }), + false, + 'Settled battles must not show intent forecasts.' + ); + assert.equal( + isEnemyIntentCounterplayAvailable({ phase: 'animating', battleResolved: false }), + true, + 'The final allied action must remain eligible for a defeat counterplay before settlement.' + ); + assert.equal( + isEnemyIntentCounterplayAvailable({ phase: 'resolved', battleResolved: true }), + false, + 'Resolved battles must not record late counterplay events.' + ); + + const commonGuide = enemyIntentOpeningGuide(); + const initiativeGuide = enemyIntentOpeningGuide(3); + assert.match(commonGuide, /이동·선제·전열 방호로 파훼/, 'Common intent guide must explain universal counterplay.'); + assert.doesNotMatch(commonGuide, /전술 명령/, 'Campaign-wide guide must not promise the second-battle-only command.'); + assert.match(initiativeGuide, /파훼 3회 → 전술 명령/, 'The legacy pursuit battle must retain its initiative instruction.'); + + const address = server.httpServer?.address(); + assert(address && typeof address !== 'string', 'Expected the enemy-intent verification server to listen on a local port.'); + const targetUrl = `http://127.0.0.1:${address.port}/`; + browser = await chromium.launch({ headless: true }); + + const firstScenario = battleScenarios['first-battle-zhuo-commandery']; + const genericScenario = scenarios.find((scenario, index) => index >= 32 && scenario.id !== firstScenario.id); + assert(firstScenario && genericScenario, 'Expected first and mid-campaign runtime fixtures.'); + + const runtimeScope = process.env.HEROS_ENEMY_INTENT_SCOPE; + if (runtimeScope !== 'generic') { + await verifyRuntimeIntentState(browser, targetUrl, firstScenario, { expectTutorial: true }); + } + if (runtimeScope !== 'first') { + await verifyRuntimeIntentState(browser, targetUrl, genericScenario, { verifySaveResume: true }); + } + + console.log( + `Verified campaign-wide enemy intent for ${scenarios.length} battles plus ${desktopBrowserViewport.width}x${desktopBrowserViewport.height} deployment, first tutorial, save-resume, final-defeat, settlement, and generalized-guide checks.` + ); +} finally { + await browser?.close(); + await server.close(); +} + +async function verifyRuntimeIntentState(browserInstance, targetUrl, scenario, options = {}) { + const url = new URL(targetUrl); + url.searchParams.set('debug', '1'); + url.searchParams.set('renderer', 'canvas'); + url.searchParams.set('debugBattle', scenario.id); + const page = await browserInstance.newPage(desktopBrowserContextOptions); + const pageErrors = []; + const consoleErrors = []; + page.on('pageerror', (error) => pageErrors.push(error.message)); + page.on('console', (message) => { + if (message.type() === 'error') { + consoleErrors.push(message.text()); + } + }); + + try { + await page.addInitScript(() => window.localStorage.clear()); + await page.goto(url.toString(), { waitUntil: 'domcontentloaded' }); + await waitForBattleDebugReady(page, scenario.id); + + const initialProbe = await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); + return { + state: window.__HEROS_DEBUG__?.battle(), + viewport: { width: window.innerWidth, height: window.innerHeight, dpr: window.devicePixelRatio }, + scene: scene ? { width: scene.scale.width, height: scene.scale.height } : null + }; + }); + const initial = initialProbe.state; + assert.deepEqual( + initialProbe.viewport, + { width: desktopBrowserViewport.width, height: desktopBrowserViewport.height, dpr: 1 }, + `${scenario.id}: browser did not use the required FHD DPR1 viewport.` + ); + assert.deepEqual( + initialProbe.scene, + { width: desktopBrowserViewport.width, height: desktopBrowserViewport.height }, + `${scenario.id}: battle scene did not use FHD dimensions.` + ); + if (initial?.phase === 'deployment') { + assert.equal(initial.counterplayFeedback?.active, false, `${scenario.id}: counterplay active during deployment.`); + assert.equal(initial.counterplayFeedback?.forecastVisible, false, `${scenario.id}: forecast visible during deployment.`); + assert.equal(initial.enemyIntentPreviews?.length, 0, `${scenario.id}: deployment produced stale intent plans.`); + assert.equal(initial.enemyIntentVisualCount, 0, `${scenario.id}: deployment produced intent visuals.`); + await page.evaluate(() => window.__HEROS_GAME__?.scene.getScene('BattleScene')?.confirmPreBattleDeployment?.()); + } + + await page.waitForFunction((battleId) => { + try { + const state = window.__HEROS_DEBUG__?.battle(); + const assetsReady = state?.combatAssets?.status === 'ready' || state?.combatAssets?.status === 'degraded'; + return state?.battleId === battleId && state.phase === 'idle' && assetsReady; + } catch { + return false; + } + }, scenario.id, { timeout: 90000 }); + + if (options.expectTutorial) { + await page.waitForFunction( + () => { + try { + return window.__HEROS_DEBUG__?.battle()?.firstBattleTutorial?.active === true; + } catch { + return false; + } + }, + undefined, + { timeout: 30000 } + ); + } + + const live = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); + const aliveEnemyCount = live?.units?.filter((unit) => unit.faction === 'enemy' && unit.hp > 0).length ?? 0; + const deployedAllyCount = live?.units?.filter((unit) => unit.faction === 'ally').length ?? 0; + const liveTileCount = new Set( + live?.units?.filter((unit) => unit.hp > 0).map((unit) => `${unit.x}:${unit.y}`) ?? [] + ).size; + const liveUnitCount = live?.units?.filter((unit) => unit.hp > 0).length ?? 0; + assert.ok(deployedAllyCount <= scenario.sortie.sortieLimit, `${scenario.id}: fallback sortie exceeded its limit.`); + assert.equal(liveTileCount, liveUnitCount, `${scenario.id}: fallback sortie created overlapping live units.`); + assert.equal(live?.counterplayFeedback?.active, true, `${scenario.id}: campaign counterplay inactive.`); + assert.equal(live?.counterplayFeedback?.forecastVisible, true, `${scenario.id}: allied forecast inactive.`); + assert.equal(live?.counterplayFeedback?.scope, 'campaign', `${scenario.id}: intent scope is not campaign-wide.`); + assert.equal(live?.enemyIntentPreviews?.length, aliveEnemyCount, `${scenario.id}: not every enemy received a plan.`); + assert.equal(live?.counterplayFeedback?.byUnit?.length, deployedAllyCount, `${scenario.id}: contribution roles omit allies.`); + assert.ok(live?.enemyIntentVisualCount > 0, `${scenario.id}: no visible intent marker was created.`); + assert.ok(live?.triggeredBattleEvents?.includes('opening'), `${scenario.id}: opening guide event did not run.`); + + if (options.expectTutorial) { + assert.equal(live?.firstBattleTutorial?.active, true, 'First-battle tutorial was displaced by intent rendering.'); + assert.ok(live?.firstBattleTutorial?.step, 'First-battle tutorial lost its active step.'); + } + + if (options.verifySaveResume) { + const beforeSave = normalizedIntentPlans(live.enemyIntentPreviews); + const saved = await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); + scene?.saveBattleState?.(1); + return Object.keys(window.localStorage).some((key) => key.includes(`battle:${window.__HEROS_DEBUG__?.battle()?.battleId}:slot-1`)); + }); + assert.equal(saved, true, `${scenario.id}: battle slot was not persisted.`); + await page.waitForFunction(() => { + try { + const transition = window.__HEROS_DEBUG__?.battle()?.battleHud?.panelTransition; + return !transition || (!transition.active && transition.completedRevision === transition.revision); + } catch { + return false; + } + }, undefined, { timeout: 30000 }); + await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))); + const restoreProbe = await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); + scene?.clearEnemyIntentForecast?.(); + const afterClear = scene?.enemyIntentForecasts?.size ?? -1; + const readable = Boolean(scene?.readBattleSaveState?.(1)); + scene?.loadBattleState?.(1); + return { + afterClear, + readable, + afterLoad: scene?.enemyIntentForecasts?.size ?? -1, + phase: scene?.phase, + activeFaction: scene?.activeFaction, + currentPlanCount: scene?.currentEnemyActionPlans?.().length ?? -1 + }; + }); + assert.equal(restoreProbe.readable, true, `${scenario.id}: newly written battle save failed validation.`); + try { + await page.waitForFunction((expectedCount) => { + try { + const state = window.__HEROS_DEBUG__?.battle(); + return ( + state?.phase === 'idle' && + state.counterplayFeedback?.forecastVisible === true && + state.enemyIntentPreviews?.length === expectedCount + ); + } catch { + return false; + } + }, aliveEnemyCount, { timeout: 30000 }); + } catch (error) { + const diagnostic = await page.evaluate(() => { + let state; + let debugError; + try { + state = window.__HEROS_DEBUG__?.battle(); + } catch (caught) { + debugError = String(caught); + } + return { + state: state ? { + battleId: state.battleId, + phase: state.phase, + activeFaction: state.activeFaction, + previewCount: state.enemyIntentPreviews?.length ?? -1, + aliveEnemyCount: state.units?.filter((unit) => unit.faction === 'enemy' && unit.hp > 0).length ?? -1, + forecastVisible: state.counterplayFeedback?.forecastVisible ?? false, + enemyIntentVisualCount: state.enemyIntentVisualCount ?? -1 + } : undefined, + debugError, + storage: Object.keys(window.localStorage) + .filter((key) => key.includes('battle') || key.includes('campaign')) + .map((key) => ({ key, size: window.localStorage.getItem(key)?.length ?? 0 })) + }; + }); + throw new Error( + `${scenario.id}: save-resume intent wait failed: ${JSON.stringify({ restoreProbe, ...diagnostic, consoleErrors })}`, + { cause: error } + ); + } + const resumed = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); + assert.deepEqual( + normalizedIntentPlans(resumed.enemyIntentPreviews), + beforeSave, + `${scenario.id}: save-resume changed deterministic intent targets or damage.` + ); + assert.equal(resumed.counterplayFeedback?.recentEvents?.length, 0, `${scenario.id}: resume created phantom counterplay.`); + } + + assert.deepEqual(pageErrors, [], `${scenario.id}: browser errors: ${pageErrors.join(' | ')}`); + assert.deepEqual(consoleErrors, [], `${scenario.id}: console errors: ${consoleErrors.join(' | ')}`); + } finally { + await page.close(); + } +} + +async function waitForBattleDebugReady(page, battleId) { + await page.waitForFunction(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); + if (!scene?.scene?.isActive?.() || !scene.layout || !scene.mapBackground || scene.unitViews?.size === 0) { + return false; + } + if (scene.phase === 'deployment') { + return Boolean(scene.deploymentPanelLayout && scene.sidePanelObjects?.length > 0); + } + return Boolean(scene.currentSidePanelView && scene.sideQuickTabLayout && scene.sidePanelObjects?.length > 0); + }, undefined, { timeout: 90000 }); + await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))); + await page.waitForFunction((expectedBattleId) => { + try { + const state = window.__HEROS_DEBUG__?.battle(); + return ( + state?.battleId === expectedBattleId && + state.mapBackgroundReady === true && + (state.phase === 'deployment' || state.phase === 'idle') + ); + } catch { + return false; + } + }, battleId, { timeout: 30000 }); +} + +function normalizedIntentPlans(plans = []) { + return plans + .map((plan) => ({ + enemyId: plan.enemyId, + kind: plan.kind, + destination: plan.destination, + targetId: plan.targetId, + usableId: plan.usableId, + damage: plan.damage, + hitRate: plan.hitRate + })) + .sort((left, right) => left.enemyId.localeCompare(right.enemyId)); +} diff --git a/scripts/verify-growth-consistency.mjs b/scripts/verify-growth-consistency.mjs new file mode 100644 index 0000000..be8aec2 --- /dev/null +++ b/scripts/verify-growth-consistency.mjs @@ -0,0 +1,164 @@ +import { readFile } from 'node:fs/promises'; +import { createServer } from 'vite'; + +const storage = new Map(); +globalThis.window = { + localStorage: { + getItem(key) { + return storage.has(key) ? storage.get(key) : null; + }, + setItem(key, value) { + storage.set(key, String(value)); + }, + removeItem(key) { + storage.delete(key); + } + } +}; + +const server = await createServer({ + logLevel: 'error', + server: { middlewareMode: true, hmr: false }, + appType: 'custom' +}); + +try { + const { calculateEquipmentBonuses } = await server.ssrLoadModule('/src/game/data/battleItems.ts'); + const { applyCampaignUnitProgress } = await server.ssrLoadModule('/src/game/data/unitProgress.ts'); + const { + campaignStorageKey, + createInitialCampaignState, + loadCampaignState + } = await server.ssrLoadModule('/src/game/state/campaignState.ts'); + + const equipment = { + weapon: { itemId: 'green-dragon-glaive', level: 4, exp: 12 }, + armor: { itemId: 'oath-robe', level: 3, exp: 8 }, + accessory: { itemId: 'bravery-token', level: 5, exp: 4 } + }; + const equipmentBonuses = calculateEquipmentBonuses(equipment); + assert( + sameJson(equipmentBonuses, { attack: 9, defense: 4, strategy: 5 }), + `Expected item and slot-level equipment bonuses to share one calculation: ${JSON.stringify(equipmentBonuses)}` + ); + + const scenarioUnit = createUnit({ equipment }); + const savedProgress = createUnit({ + level: 5, + exp: 42, + hp: 27, + maxHp: 38, + attack: 14, + stats: { might: 81, intelligence: 85, leadership: 90, agility: 73, luck: 91 }, + equipment + }); + const merged = applyCampaignUnitProgress(scenarioUnit, savedProgress); + assert( + merged.level === 5 && + merged.exp === 42 && + merged.hp === 27 && + merged.maxHp === 38 && + merged.attack === 14 && + sameJson(merged.stats, savedProgress.stats) && + sameJson(merged.equipment, savedProgress.equipment) && + merged.x === scenarioUnit.x && + merged.y === scenarioUnit.y, + `Expected the next battle to preserve all saved growth while keeping authored deployment: ${JSON.stringify(merged)}` + ); + merged.stats.might += 20; + merged.equipment.weapon.level += 1; + assert( + savedProgress.stats.might === 81 && savedProgress.equipment.weapon.level === 4, + 'Expected merged campaign progress to be deeply cloned.' + ); + + const legacyProgress = createUnit({ level: 5, exp: 17, hp: 28, equipment }); + const recovered = applyCampaignUnitProgress(scenarioUnit, legacyProgress); + assert( + recovered.level === 5 && + recovered.attack === 11 && + recovered.maxHp === 36 && + sameJson(recovered.stats, { might: 78, intelligence: 80, leadership: 86, agility: 70, luck: 88 }), + `Expected older saves that lost level-up fields to recover authored growth floors: ${JSON.stringify(recovered)}` + ); + + const rawSave = createInitialCampaignState(); + rawSave.step = 'first-camp'; + rawSave.roster = [{ + ...savedProgress, + attack: '14', + stats: Object.fromEntries(Object.entries(savedProgress.stats).map(([key, value]) => [key, String(value)])), + equipment: Object.fromEntries( + Object.entries(savedProgress.equipment).map(([slot, state]) => [slot, { ...state, level: String(state.level), exp: String(state.exp) }]) + ) + }]; + storage.set(campaignStorageKey, JSON.stringify(rawSave)); + const loaded = loadCampaignState(); + const loadedUnit = loaded.roster[0]; + const loadedMerge = applyCampaignUnitProgress(scenarioUnit, loadedUnit); + assert( + loadedUnit.attack === 14 && + sameJson(loadedUnit.stats, savedProgress.stats) && + sameJson(loadedUnit.equipment, savedProgress.equipment) && + loadedMerge.attack === 14 && + sameJson(loadedMerge.stats, savedProgress.stats), + `Expected legacy-compatible normalization and reload to retain every growth field: ${JSON.stringify({ loadedUnit, loadedMerge })}` + ); + + const [battleSource, campSource] = await Promise.all([ + readFile('src/game/scenes/BattleScene.ts', 'utf8'), + readFile('src/game/scenes/CampScene.ts', 'utf8') + ]); + assert( + battleSource.includes('return mergeCampaignUnitProgress(unit, progress);'), + 'Expected BattleScene campaign hydration to use the shared full-progress merger.' + ); + assert( + countOccurrences(battleSource, 'calculateEquipmentBonuses(unit.equipment).') === 3 && + countOccurrences(campSource, 'calculateEquipmentBonuses(unit.equipment).') === 3, + 'Expected CampScene display and BattleScene combat to use the same three equipment bonus projections.' + ); + + console.log('Verified full character-growth carryover, legacy growth-floor recovery, save normalization, and shared camp/battle equipment level bonuses.'); +} finally { + await server.close(); +} + +function createUnit(overrides = {}) { + return { + id: 'liu-bei', + name: '유비', + faction: 'ally', + className: '군주', + classKey: 'lord', + level: 3, + exp: 0, + hp: 32, + maxHp: 32, + attack: 9, + move: 4, + stats: { might: 76, intelligence: 78, leadership: 84, agility: 70, luck: 88 }, + equipment: { + weapon: { itemId: 'training-sword', level: 1, exp: 0 }, + armor: { itemId: 'cloth-armor', level: 1, exp: 0 }, + accessory: { itemId: 'grain-pouch', level: 1, exp: 0 } + }, + x: 3, + y: 14, + ...overrides + }; +} + +function countOccurrences(source, needle) { + return source.split(needle).length - 1; +} + +function sameJson(actual, expected) { + return JSON.stringify(actual) === JSON.stringify(expected); +} + +function assert(condition, message) { + if (!condition) { + throw new Error(message); + } +} diff --git a/scripts/verify-release-candidate.mjs b/scripts/verify-release-candidate.mjs index 5166709..09d4cae 100644 --- a/scripts/verify-release-candidate.mjs +++ b/scripts/verify-release-candidate.mjs @@ -1255,6 +1255,7 @@ try { const originalPlayCombatCutIn = scene.playCombatCutIn; const originalApplyDefeatedStyle = scene.applyDefeatedStyle; const originalResolveCounterAttack = scene.resolveCounterAttack; + const originalCombatPresentationMode = scene.combatPresentationMode; const methodOwnership = { resolveCombatAction: Object.prototype.hasOwnProperty.call(scene, 'resolveCombatAction'), playCombatCutIn: Object.prototype.hasOwnProperty.call(scene, 'playCombatCutIn'), @@ -1283,6 +1284,9 @@ try { setDebugForceHit(true); setDebugForceBondChain('1'); + // This fixture verifies full-screen pursuit/counter ordering and defeated-style timing. + // Force the opt-in "always" mode now that ordinary counters use the compact map tier by default. + scene.combatPresentationMode = 'always'; scene.rollPercent = () => false; scene.resolveCombatAction = function (...args) { const result = originalResolveCombatAction.apply(this, args); @@ -1352,6 +1356,7 @@ try { restoreMethod('playCombatCutIn', originalPlayCombatCutIn); restoreMethod('applyDefeatedStyle', originalApplyDefeatedStyle); restoreMethod('resolveCounterAttack', originalResolveCounterAttack); + scene.combatPresentationMode = originalCombatPresentationMode; if (hadOwnRollPercent) { scene.rollPercent = originalRollPercent; } else { diff --git a/scripts/verify-static-data.mjs b/scripts/verify-static-data.mjs index 191901f..c56f09e 100644 --- a/scripts/verify-static-data.mjs +++ b/scripts/verify-static-data.mjs @@ -4,6 +4,8 @@ const checks = [ 'scripts/verify-desktop-browser-viewport.mjs', 'scripts/verify-campaign-flow-data.mjs', 'scripts/verify-campaign-save-normalization.mjs', + 'scripts/verify-growth-consistency.mjs', + 'scripts/verify-combat-presentation-settings.mjs', 'scripts/verify-battle-save-normalization.mjs', 'scripts/verify-battle-usables.mjs', 'scripts/verify-sortie-synergy.mjs', diff --git a/src/game/data/battleItems.ts b/src/game/data/battleItems.ts index d79df71..9c42559 100644 --- a/src/game/data/battleItems.ts +++ b/src/game/data/battleItems.ts @@ -22,6 +22,12 @@ export type ItemDefinition = { effects: string[]; }; +export type EquipmentBonuses = { + attack: number; + defense: number; + strategy: number; +}; + export const equipmentSlotLabels: Record = { weapon: '무기', armor: '방어구', @@ -264,3 +270,18 @@ export function isEquipmentInventoryLabel(label: string) { export function equipmentExpToNext(level: number) { return 50 + Math.min(level, 9) * 20; } + +export function calculateEquipmentBonuses(equipment: EquipmentSet): EquipmentBonuses { + return equipmentSlots.reduce( + (bonuses, slot) => { + const state = equipment[slot]; + const item = getItem(state.itemId); + const levelBonus = Math.max(0, Math.floor(state.level) - 1); + bonuses.attack += (item.attackBonus ?? 0) + (slot === 'weapon' ? levelBonus : 0); + bonuses.defense += (item.defenseBonus ?? 0) + (slot === 'armor' ? levelBonus : 0); + bonuses.strategy += (item.strategyBonus ?? 0) + (slot === 'accessory' ? levelBonus : 0); + return bonuses; + }, + { attack: 0, defense: 0, strategy: 0 } + ); +} diff --git a/src/game/data/enemyIntent.ts b/src/game/data/enemyIntent.ts new file mode 100644 index 0000000..eb2029c --- /dev/null +++ b/src/game/data/enemyIntent.ts @@ -0,0 +1,43 @@ +export type EnemyIntentBattlePhase = + | 'deployment' + | 'idle' + | 'moving' + | 'command' + | 'targeting' + | 'animating' + | 'resolved'; + +type EnemyIntentRuntimeState = { + phase: EnemyIntentBattlePhase; + battleResolved: boolean; +}; + +export type EnemyIntentForecastState = EnemyIntentRuntimeState & { + activeFaction: 'ally' | 'enemy'; +}; + +/** + * Enemy intent is a campaign-wide combat rule. Deployment and settled battles + * deliberately stay quiet, while every playable allied turn exposes the same + * forecast regardless of scenario id. + */ +export function isEnemyIntentForecastAvailable(state: EnemyIntentForecastState) { + return ( + state.activeFaction === 'ally' && + state.phase !== 'deployment' && + state.phase !== 'resolved' && + !state.battleResolved + ); +} + +/** Counterplay is settled after an allied action, including a final defeat. */ +export function isEnemyIntentCounterplayAvailable(state: EnemyIntentRuntimeState) { + return state.phase !== 'deployment' && state.phase !== 'resolved' && !state.battleResolved; +} + +export function enemyIntentOpeningGuide(legacyInitiativeThreshold?: number) { + const common = '적 의도 · 검=공격 · 발=추격 · 이동·선제·전열 방호로 파훼'; + return legacyInitiativeThreshold + ? `${common} · 파훼 ${legacyInitiativeThreshold}회 → 전술 명령` + : common; +} diff --git a/src/game/data/unitProgress.ts b/src/game/data/unitProgress.ts new file mode 100644 index 0000000..1cf9458 --- /dev/null +++ b/src/game/data/unitProgress.ts @@ -0,0 +1,55 @@ +import type { UnitClassKey } from './battleRules'; +import type { UnitData, UnitStats } from './scenario'; + +const levelUpStatKeysByClass: Record> = { + lord: ['might', 'intelligence', 'leadership'], + infantry: ['might', 'leadership'], + cavalry: ['might', 'leadership', 'agility'], + spearman: ['might', 'leadership'], + archer: ['might', 'intelligence'], + strategist: ['intelligence', 'leadership'], + quartermaster: ['intelligence', 'leadership'], + bandit: ['might', 'leadership'], + yellowTurban: ['might', 'intelligence', 'leadership'], + rebelLeader: ['might', 'intelligence', 'leadership'] +}; + +export function applyCampaignUnitProgress(scenarioUnit: UnitData, progress?: UnitData): UnitData { + const cloned = cloneUnitProgressData(scenarioUnit); + if (!progress || scenarioUnit.faction !== 'ally' || progress.faction !== 'ally' || progress.id !== scenarioUnit.id) { + return cloned; + } + + const level = Math.max(scenarioUnit.level, progress.level); + const levelUps = level - scenarioUnit.level; + const progressedStatKeys = new Set(levelUpStatKeysByClass[scenarioUnit.classKey]); + const stats = (Object.keys(scenarioUnit.stats) as Array).reduce((next, key) => { + const authoredGrowthFloor = scenarioUnit.stats[key] + (progressedStatKeys.has(key) ? levelUps : 0); + next[key] = Math.min(255, Math.max(progress.stats[key], authoredGrowthFloor)); + return next; + }, {} as UnitStats); + const maxHp = Math.max(progress.maxHp, scenarioUnit.maxHp + levelUps * 2); + + return { + ...cloned, + level, + exp: progress.exp, + hp: Math.max(1, Math.min(progress.hp, maxHp)), + maxHp, + attack: Math.max(progress.attack, scenarioUnit.attack + levelUps), + stats, + equipment: cloneUnitProgressData(progress).equipment + }; +} + +function cloneUnitProgressData(unit: UnitData): UnitData { + return { + ...unit, + stats: { ...unit.stats }, + equipment: { + weapon: { ...unit.equipment.weapon }, + armor: { ...unit.equipment.armor }, + accessory: { ...unit.equipment.accessory } + } + }; +} diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index 25993d8..68712fb 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -12,6 +12,11 @@ import { type BattleTacticalGuideEventKey } from '../data/battles'; import { getSortieFlow } from '../data/campaignFlow'; +import { + enemyIntentOpeningGuide, + isEnemyIntentCounterplayAvailable, + isEnemyIntentForecastAvailable +} from '../data/enemyIntent'; import { getTerrainRule, getUnitClass, type TerrainType, type UnitClassKey } from '../data/battleRules'; import { ensureUnitAnimations, @@ -43,6 +48,7 @@ import { type UsableCommand } from '../data/battleUsables'; import { + calculateEquipmentBonuses, equipmentExpToNext, equipmentSlotLabels, equipmentSlots, @@ -51,6 +57,7 @@ import { type EquipmentSlot, type ItemDefinition } from '../data/battleItems'; +import { applyCampaignUnitProgress as mergeCampaignUnitProgress } from '../data/unitProgress'; import { applySortieDeploymentPlan, createSortieDeploymentPlan, @@ -126,6 +133,16 @@ import { type BattleSaveTacticalCommandState, type BattleSaveUnitStats } from '../state/battleSaveState'; +import { + combatPresentationImportanceReason, + combatPresentationModeLabel, + loadCombatPresentationMode, + nextCombatPresentationMode, + saveCombatPresentationMode, + shouldUseFullCombatCutIn, + type CombatPresentationImportance, + type CombatPresentationMode +} from '../settings/combatPresentation'; import { palette } from '../ui/palette'; import { startLazyScene } from './lazyScenes'; @@ -1363,7 +1380,7 @@ type RosterTab = 'ally' | 'enemy'; type ActiveFaction = 'ally' | 'enemy'; const battleSpeedOptions = ['normal', 'fast', 'very-fast'] as const; type BattleSpeed = (typeof battleSpeedOptions)[number]; -type MapMenuAction = 'endTurn' | 'threat' | 'save' | 'load' | 'roster' | 'bond' | 'situation' | 'speed' | 'bgm' | 'close'; +type MapMenuAction = 'endTurn' | 'threat' | 'save' | 'load' | 'roster' | 'bond' | 'situation' | 'speed' | 'presentation' | 'bgm' | 'close'; type EnemyAiBehavior = 'aggressive' | 'guard' | 'hold'; type BattleOutcome = 'victory' | 'defeat'; type SaveSlotMode = 'save' | 'load'; @@ -1990,6 +2007,15 @@ type CombatCutInStageSnapshot = { mapBacked: boolean; }; +type CombatPresentationSnapshot = { + mode: CombatPresentationMode; + tier: 'full' | 'map'; + reason: string; + action: CombatCutInStageMode; + actorId: string; + targetId: string; +}; + type TacticalInitiativeCombatEffect = { role?: TacticalInitiativeRole; damageBonus: number; @@ -3376,6 +3402,7 @@ const mapMenuLabels: Partial> = { bond: '공명', situation: '전황', speed: '속도', + presentation: '전투 연출', bgm: 'BGM', close: '닫기' }; @@ -3515,6 +3542,9 @@ export class BattleScene extends Phaser.Scene { private mapMenuObjects: Array = []; private mapMenuLayout?: MapMenuLayout; private battleSpeed: BattleSpeed = 'normal'; + private combatPresentationMode: CombatPresentationMode = 'important'; + private combatPresentationCount = { full: 0, map: 0 }; + private combatPresentationLast?: CombatPresentationSnapshot; private fastForwardHeld = false; private battleSpeedControlObjects: Phaser.GameObjects.GameObject[] = []; private tacticalInitiativeChipObjects: Phaser.GameObjects.GameObject[] = []; @@ -3794,6 +3824,9 @@ export class BattleScene extends Phaser.Scene { this.battleOutcome = undefined; this.phase = 'idle'; this.battleSpeed = this.loadBattleSpeed(); + this.combatPresentationMode = loadCombatPresentationMode(); + this.combatPresentationCount = { full: 0, map: 0 }; + this.combatPresentationLast = undefined; this.fastForwardHeld = false; soundDirector.playMusic('battle-prep'); this.input.mouse?.disableContextMenu(); @@ -4450,7 +4483,27 @@ export class BattleScene extends Phaser.Scene { } private effectiveSortieUnitIds(campaign?: CampaignState) { - return this.launchSortieUnitIds.length > 0 ? this.launchSortieUnitIds : campaign?.selectedSortieUnitIds ?? []; + const configured = this.launchSortieUnitIds.length > 0 + ? this.launchSortieUnitIds + : campaign?.selectedSortieUnitIds ?? []; + const availableAllyIds = new Set( + initialBattleUnits.filter((unit) => unit.faction === 'ally').map((unit) => unit.id) + ); + const limit = battleScenario.sortie?.sortieLimit ?? availableAllyIds.size; + const normalized = [...new Set(configured)] + .filter((unitId) => availableAllyIds.has(unitId)) + .slice(0, limit); + if (normalized.length > 0 || !battleScenario.sortie) { + return normalized; + } + + return [...new Set([ + ...(battleScenario.sortie.requiredUnits ?? []), + ...battleScenario.sortie.recommendedUnits.map((entry) => entry.unitId), + ...availableAllyIds + ])] + .filter((unitId) => availableAllyIds.has(unitId)) + .slice(0, limit); } private scenarioRecommendedSortieFormationAssignments() { @@ -4533,6 +4586,12 @@ export class BattleScene extends Phaser.Scene { )); } + private intentCounterplayAssignments() { + return battleUnits + .filter((unit) => unit.faction === 'ally') + .map((unit) => ({ unit, role: this.sortieRoleForUnit(unit) ?? 'reserve' as SortieFormationRole })); + } + private sortieRoleGroups() { const assignments = this.sortieRoleAssignments(); return coreSortieSynergyRoles.map((role) => ({ @@ -4837,7 +4896,7 @@ export class BattleScene extends Phaser.Scene { } const parts = this.sortieRoleAssignments().map(({ role, unit }) => { const stats = this.statsFor(unit.id); - const counterplay = this.isFirstPursuitRoleEffectBattle() ? `${this.intentCounterplayContributionText(stats)}·` : ''; + const counterplay = `${this.intentCounterplayContributionText(stats)}·`; if (role === 'front') { return `전열 ${unit.name}: ${counterplay}감소 ${stats.sortiePreventedDamage}·반격 추가 ${stats.sortieBonusDamage}`; } @@ -4846,6 +4905,10 @@ export class BattleScene extends Phaser.Scene { } return `후원 ${unit.name}: ${counterplay}추가 피해 ${stats.sortieBonusDamage}·회복 ${stats.sortieBonusHealing}`; }); + const assignedUnitIds = new Set(this.sortieRoleAssignments().map(({ unit }) => unit.id)); + this.intentCounterplayAssignments() + .filter(({ unit }) => !assignedUnitIds.has(unit.id) && this.intentCounterplayTotal(this.statsFor(unit.id)) > 0) + .forEach(({ unit }) => parts.push(`${unit.name}: ${this.intentCounterplayContributionText(this.statsFor(unit.id))}`)); if (this.firstPursuitTrinityConfigured()) { parts.push(this.firstPursuitTrinityActive() ? '삼재진: 공명 2칸 유지' : '삼재진: 퇴각으로 해제'); } @@ -4853,18 +4916,22 @@ export class BattleScene extends Phaser.Scene { } private sortieContributionTotals() { - return this.sortieRoleAssignments().reduce( + const totals = this.sortieRoleAssignments().reduce( (totals, { unit }) => { const stats = this.statsFor(unit.id); totals.bonusDamage += stats.sortieBonusDamage; totals.preventedDamage += stats.sortiePreventedDamage; totals.bonusHealing += stats.sortieBonusHealing; totals.extendedBondTriggers += stats.sortieExtendedBondTriggers; - totals.counterplays += this.intentCounterplayTotal(stats); return totals; }, { bonusDamage: 0, preventedDamage: 0, bonusHealing: 0, extendedBondTriggers: 0, counterplays: 0 } ); + totals.counterplays = this.intentCounterplayAssignments().reduce( + (count, { unit }) => count + this.intentCounterplayTotal(this.statsFor(unit.id)), + 0 + ); + return totals; } private tacticalInitiativeResultText() { @@ -4889,10 +4956,11 @@ export class BattleScene extends Phaser.Scene { private sortieUnitContributionLine(unit: UnitData) { const role = this.sortieRoleForUnit(unit); if (!role || !sortieRoleEffects[role]) { - return undefined; + const stats = this.statsFor(unit.id); + return this.intentCounterplayTotal(stats) > 0 ? this.intentCounterplayContributionText(stats) : undefined; } const stats = this.statsFor(unit.id); - const counterplay = this.isFirstPursuitRoleEffectBattle() ? ` · ${this.intentCounterplayContributionText(stats)}` : ''; + const counterplay = ` · ${this.intentCounterplayContributionText(stats)}`; const extendedBond = stats.sortieExtendedBondTriggers > 0 ? ` · 공명 ${stats.sortieExtendedBondTriggers}회` : ''; if (role === 'front') { return `전열${counterplay} · 피해 ${stats.sortiePreventedDamage} 감소 · 반격 +${stats.sortieBonusDamage}${extendedBond}`; @@ -4904,20 +4972,8 @@ export class BattleScene extends Phaser.Scene { } private applyCampaignUnitProgress(unit: UnitData, campaign?: CampaignState) { - const cloned = cloneUnitData(unit); const progress = campaign?.roster.find((candidate) => candidate.id === unit.id); - if (!progress || unit.faction !== 'ally') { - return cloned; - } - - return { - ...cloned, - level: progress.level, - exp: progress.exp, - hp: Math.max(1, Math.min(progress.hp, progress.maxHp)), - maxHp: progress.maxHp, - equipment: JSON.parse(JSON.stringify(progress.equipment)) as UnitData['equipment'] - }; + return mergeCampaignUnitProgress(unit, progress); } private applyCampaignBondProgress(bond: BattleBond, campaign?: CampaignState) { @@ -6088,6 +6144,25 @@ export class BattleScene extends Phaser.Scene { } } + private cycleCombatPresentationMode() { + this.combatPresentationMode = nextCombatPresentationMode(this.combatPresentationMode); + saveCombatPresentationMode(this.combatPresentationMode); + this.renderSituationPanel( + `전투 연출: ${combatPresentationModeLabel(this.combatPresentationMode)}\n` + + (this.combatPresentationMode === 'always' + ? '모든 공격과 책략을 전체 화면으로 보여줍니다.' + : this.combatPresentationMode === 'important' + ? '고유 전법·치명타·격파·공명·레벨 상승만 전체 화면으로 보여줍니다.' + : '모든 행동을 빠른 지도 연출로 보여줍니다.') + ); + } + + private combatPresentationMenuLabel() { + return this.combatPresentationMode === 'important' + ? '중요만' + : combatPresentationModeLabel(this.combatPresentationMode); + } + private battleSpeedFactor() { const heldFastForward = this.activeFaction === 'enemy' && this.fastForwardHeld ? 1.75 : 1; return battleSpeedFactors[this.battleSpeed] * heldFastForward; @@ -8509,13 +8584,18 @@ export class BattleScene extends Phaser.Scene { } private enemyIntentForecastEnabled() { - return ( - this.isFirstPursuitRoleEffectBattle() && - this.activeFaction === 'ally' && - this.phase !== 'deployment' && - this.phase !== 'resolved' && - !this.battleOutcome - ); + return isEnemyIntentForecastAvailable({ + activeFaction: this.activeFaction, + phase: this.phase, + battleResolved: Boolean(this.battleOutcome) + }); + } + + private enemyIntentCounterplayEnabled() { + return isEnemyIntentCounterplayAvailable({ + phase: this.phase, + battleResolved: Boolean(this.battleOutcome) + }); } private currentEnemyActionPlans() { @@ -8683,7 +8763,10 @@ export class BattleScene extends Phaser.Scene { return '아군 턴을 종료하시겠습니까?'; } const groups = new Map(); - this.currentEnemyActionPlans().forEach((plan) => { + const plans = this.enemyIntentForecasts.size > 0 + ? Array.from(this.enemyIntentForecasts.values()) + : this.currentEnemyActionPlans(); + plans.forEach((plan) => { if (!plan.target || plan.kind === 'wait') { return; } @@ -8757,7 +8840,7 @@ export class BattleScene extends Phaser.Scene { pendingMove: PendingMove | undefined, outcome: IntentCounterplayActionOutcome = {} ) { - if (!this.isFirstPursuitRoleEffectBattle() || !pendingMove || pendingMove.unit.id !== actor.id) { + if (!this.enemyIntentCounterplayEnabled() || !pendingMove || pendingMove.unit.id !== actor.id) { return undefined; } @@ -8853,7 +8936,7 @@ export class BattleScene extends Phaser.Scene { preventedDamage: number ) { if ( - !this.isFirstPursuitRoleEffectBattle() || + !this.enemyIntentCounterplayEnabled() || isCounter || preventedDamage <= 0 || attacker.faction !== 'enemy' || @@ -8880,7 +8963,7 @@ export class BattleScene extends Phaser.Scene { private isIntentGuardCounterplayResult(result: CombatResult) { return ( - this.isFirstPursuitRoleEffectBattle() && + this.enemyIntentCounterplayEnabled() && !result.isCounter && result.attacker.faction === 'enemy' && result.defender.faction === 'ally' && @@ -8903,7 +8986,7 @@ export class BattleScene extends Phaser.Scene { } private intentCounterplayDebugState() { - const byUnit = this.firstPursuitRoleAssignments().map(({ unit, role }) => { + const byUnit = this.intentCounterplayAssignments().map(({ unit, role }) => { const stats = this.statsFor(unit.id); return { unitId: unit.id, @@ -8924,7 +9007,9 @@ export class BattleScene extends Phaser.Scene { { intentDefeats: 0, intentLineBreaks: 0, intentGuardSuccesses: 0, total: 0 } ); return { - active: this.isFirstPursuitRoleEffectBattle(), + active: this.enemyIntentCounterplayEnabled(), + forecastVisible: this.enemyIntentForecastEnabled(), + scope: 'campaign', totals, byUnit, pending: this.pendingMove @@ -11022,7 +11107,7 @@ export class BattleScene extends Phaser.Scene { const result = this.resolveCombatAction(attacker, target, action, false, usable); this.clearMarkers(); await this.showBondMapEffect(result); - await this.playCombatCutIn(result); + await this.presentCombatResult(result); if (result.bondChain?.defeated) { this.applyDefeatedStyle(result.defender); this.updateMiniMap(); @@ -11030,7 +11115,7 @@ export class BattleScene extends Phaser.Scene { result.counter = this.resolveCounterAttack(result); if (result.counter) { await this.delay(180); - await this.playCombatCutIn(result.counter); + await this.presentCombatResult(result.counter); } await this.showCombatExchangeMapResults(result); await this.delay(result.counter ? 220 : 160); @@ -11059,7 +11144,7 @@ export class BattleScene extends Phaser.Scene { this.phase = 'animating'; const result = this.resolveSupportAction(user, target, usable); this.clearMarkers(); - await this.playSupportCutIn(result); + await this.presentSupportResult(result); this.showSupportMapResult(result); await this.delay(160); this.finishUnitAction(user, this.formatSupportResult(result)); @@ -12229,9 +12314,9 @@ export class BattleScene extends Phaser.Scene { if (doctrineActive) { const totals = this.sortieContributionTotals(); - const specialTactics = this.isFirstPursuitRoleEffectBattle() - ? `파훼 ${totals.counterplays} · ${this.tacticalInitiativeResultText()} · ` - : ''; + const specialTactics = `파훼 ${totals.counterplays} · ${ + this.isFirstPursuitRoleEffectBattle() ? `${this.tacticalInitiativeResultText()} · ` : '' + }`; const hiddenSummary = hiddenAllyCount > 0 ? ` · 외 ${hiddenAllyCount}명 정산` : ''; const contributionSummary = this.trackResultObject(this.add.text( left + panelWidth - 44, @@ -16381,9 +16466,7 @@ export class BattleScene extends Phaser.Scene { ...(this.launchSortieRecommendation ? [`선택 전략 · ${this.launchSortieRecommendation.label} · ${this.launchSortieRecommendation.summary}`] : []), - ...(this.isFirstPursuitRoleEffectBattle() - ? [`적 의도 · 검=공격 · 발=추격 · 파훼 ${tacticalInitiativeThreshold}회 → 전술 명령`] - : []), + enemyIntentOpeningGuide(this.isFirstPursuitRoleEffectBattle() ? tacticalInitiativeThreshold : undefined), ...(battleScenario.id === 'first-battle-zhuo-commandery' ? ['첫 행동 · 아군 선택 → 파란 이동 칸 → 명령 공격 → 붉은 적 선택'] : []), @@ -17031,7 +17114,7 @@ export class BattleScene extends Phaser.Scene { this.phase = 'idle'; this.refreshUnitLegibilityStyles(); - const actions: MapMenuAction[] = ['endTurn', 'threat', 'save', 'load', 'roster', 'bond', 'situation', 'speed', 'bgm', 'close']; + const actions: MapMenuAction[] = ['endTurn', 'threat', 'save', 'load', 'roster', 'bond', 'situation', 'speed', 'presentation', 'bgm', 'close']; const menuWidth = 148; const buttonHeight = 34; const padding = 8; @@ -17126,6 +17209,10 @@ export class BattleScene extends Phaser.Scene { this.cycleBattleSpeed(false); return; } + if (action === 'presentation') { + this.cycleCombatPresentationMode(); + return; + } if (action === 'bgm') { soundDirector.setMuted(!soundDirector.isMuted()); this.renderSituationPanel(`BGM을 ${soundDirector.isMuted() ? '껐습니다' : '켰습니다'}.`); @@ -17172,6 +17259,9 @@ export class BattleScene extends Phaser.Scene { if (action === 'speed') { return `속도 ${battleSpeedLabels[this.battleSpeed]}`; } + if (action === 'presentation') { + return `연출 ${this.combatPresentationMenuLabel()}`; + } return mapMenuLabels[action] ?? action; } @@ -17507,7 +17597,8 @@ export class BattleScene extends Phaser.Scene { this.launchSortieRecommendation = this.normalizeLaunchSortieRecommendation(state.sortieRecommendation); this.applyBattleSaveState(state); this.renderSituationPanel(`슬롯 ${slot}의 전투를 불러왔습니다.\n${this.formatSavedAt(state.savedAt)}`); - } catch { + } catch (error) { + console.error('[Battle Save] Failed to restore battle state.', error); this.renderSituationPanel('저장 데이터를 불러오지 못했습니다.'); } } @@ -17956,11 +18047,11 @@ export class BattleScene extends Phaser.Scene { this.renderSituationPanel(`적 행동: ${enemy.name}\n${plan.target.name} 공격`); this.centerCameraOnTile(Math.floor((enemy.x + plan.target.x) / 2), Math.floor((enemy.y + plan.target.y) / 2)); const result = this.resolveAttack(enemy, plan.target); - await this.playCombatCutIn(result); + await this.presentCombatResult(result); result.counter = this.resolveCounterAttack(result); if (result.counter) { await this.delay(180); - await this.playCombatCutIn(result.counter); + await this.presentCombatResult(result.counter); } await this.showCombatExchangeMapResults(result); await this.delay(result.counter ? 220 : 160); @@ -17981,7 +18072,7 @@ export class BattleScene extends Phaser.Scene { this.renderSituationPanel(`적 행동: ${enemy.name}\n${target.name}에게 ${usable.name}`); this.centerCameraOnTile(Math.floor((enemy.x + target.x) / 2), Math.floor((enemy.y + target.y) / 2)); const result = this.resolveCombatAction(enemy, target, usable.command, false, usable); - await this.playCombatCutIn(result); + await this.presentCombatResult(result); this.showCombatMapResult(result); await this.delay(160); return this.formatEnemyCombatResult(result, behavior); @@ -18646,6 +18737,268 @@ export class BattleScene extends Phaser.Scene { return { snapshot, shadows }; } + private combatResultImportance(result: CombatResult): CombatPresentationImportance { + return { + signature: Boolean(result.usable?.signatureOwnerId && result.usable.signatureOwnerId === result.attacker.id), + critical: result.critical, + defeated: result.defeated || result.baseDefeated || Boolean(result.bondChain?.defeated), + resonance: Boolean(result.bondDamageBonus > 0 || result.bondChainAttempt || result.bondChain), + growth: Boolean( + result.characterGrowth.leveled || + result.attackerGrowth.leveled || + result.defenderGrowth.leveled || + result.bondGrowth.some((growth) => growth.leveled) + ) + }; + } + + private supportResultImportance(result: SupportResult): CombatPresentationImportance { + return { + signature: Boolean(result.usable.signatureOwnerId && result.usable.signatureOwnerId === result.user.id), + growth: result.characterGrowth.leveled || result.equipmentGrowth.leveled + }; + } + + private async presentCombatResult(result: CombatResult) { + const importance = this.combatResultImportance(result); + const fullCutIn = shouldUseFullCombatCutIn(this.combatPresentationMode, importance); + this.recordCombatPresentation( + fullCutIn ? 'full' : 'map', + combatPresentationImportanceReason(importance), + result.isCounter ? 'counter' : result.action, + result.attacker, + result.defender + ); + if (fullCutIn) { + await this.playCombatCutIn(result); + return; + } + await this.playCombatMapPresentation(result); + } + + private async presentSupportResult(result: SupportResult) { + const importance = this.supportResultImportance(result); + const fullCutIn = shouldUseFullCombatCutIn(this.combatPresentationMode, importance); + this.recordCombatPresentation( + fullCutIn ? 'full' : 'map', + combatPresentationImportanceReason(importance), + 'support', + result.user, + result.target + ); + if (fullCutIn) { + await this.playSupportCutIn(result); + return; + } + await this.playSupportMapPresentation(result); + } + + private recordCombatPresentation( + tier: CombatPresentationSnapshot['tier'], + reason: string, + action: CombatCutInStageMode, + actor: UnitData, + target: UnitData + ) { + this.combatPresentationCount[tier] += 1; + this.combatPresentationLast = { + mode: this.combatPresentationMode, + tier, + reason, + action, + actorId: actor.id, + targetId: target.id + }; + } + + private async playCombatMapPresentation(result: CombatResult) { + this.hideCombatCutIn(); + const attackerView = this.unitViews.get(result.attacker.id); + const defenderView = this.unitViews.get(result.defender.id); + const targetX = defenderView?.sprite.x ?? this.tileCenterX(result.defender.x); + const targetY = defenderView?.sprite.y ?? this.tileCenterY(result.defender.y); + const accent = result.critical || result.defeated ? 0xff8f5f : result.action === 'strategy' ? 0x83d6ff : 0xffdf7b; + const duration = this.scaledBattleDuration(result.critical || result.defeated ? 300 : 220, 95); + const objects: Phaser.GameObjects.GameObject[] = []; + let actorFramePromise: Promise = Promise.resolve(); + + if (attackerView?.sprite.active && attackerView.sprite.visible) { + const direction = Math.sign(targetX - attackerView.sprite.x) || (result.attacker.faction === 'ally' ? 1 : -1); + actorFramePromise = this.playUnitActionFrames( + attackerView.sprite, + result.attacker, + attackerView.direction, + this.actionPoseForCommand(result.action), + 42, + 1 + ); + this.tweens.add({ + targets: attackerView.sprite, + x: attackerView.sprite.x + direction * Math.min(20, this.layout.tileSize * 0.28), + duration: Math.max(55, Math.round(duration * 0.42)), + yoyo: true, + ease: 'Sine.easeInOut' + }); + } + + if (result.action === 'strategy') { + soundDirector.playStrategyPulse(); + } else if (result.action === 'item') { + soundDirector.playItemLaunch(); + } else if (result.attacker.classKey === 'archer' || this.attackRange(result.attacker) > 1) { + soundDirector.playArrowShot(); + soundDirector.playArrowImpact(result.hit, result.critical); + } else { + soundDirector.playMeleeSwing(result.hit, result.critical); + } + + const ring = this.add.circle(targetX, targetY - this.layout.tileSize * 0.2, this.layout.tileSize * 0.28); + ring.setStrokeStyle(result.critical || result.defeated ? 6 : 4, accent, 0.94); + ring.setDepth(12.8); + if (this.mapMask) { + ring.setMask(this.mapMask); + } + objects.push(ring); + + const spark = this.add.star( + targetX, + targetY - this.layout.tileSize * 0.2, + result.critical || result.defeated ? 8 : 6, + this.layout.tileSize * 0.12, + this.layout.tileSize * (result.critical || result.defeated ? 0.48 : 0.36), + accent, + result.hit ? 0.9 : 0.38 + ); + spark.setDepth(12.7); + if (this.mapMask) { + spark.setMask(this.mapMask); + } + objects.push(spark); + + const actionLabel = result.isCounter ? '반격' : result.usable?.name ?? commandLabels[result.action]; + const badge = this.add.text(targetX, targetY - this.layout.tileSize * 0.9, actionLabel, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: this.battleUiFontSize(result.critical || result.defeated ? 16 : 13), + color: result.hit ? '#fff2b8' : '#d9f1ff', + fontStyle: '700', + backgroundColor: 'rgba(7, 13, 19, 0.88)', + padding: { x: this.battleUiLength(8), y: this.battleUiLength(4) }, + stroke: '#120d08', + strokeThickness: this.battleUiLength(2) + }); + badge.setOrigin(0.5); + badge.setDepth(13); + if (this.mapMask) { + badge.setMask(this.mapMask); + } + objects.push(badge); + + this.tweens.add({ targets: ring, scale: result.critical || result.defeated ? 2.2 : 1.7, alpha: 0, duration, ease: 'Sine.easeOut' }); + this.tweens.add({ targets: spark, scale: 1.35, angle: 120, alpha: 0, duration, ease: 'Sine.easeOut' }); + this.tweens.add({ targets: badge, y: badge.y - this.battleUiLength(10), alpha: 0, delay: Math.round(duration * 0.45), duration: Math.round(duration * 0.55) }); + if (result.critical || result.defeated) { + this.cameras.main.shake(this.scaledBattleDuration(140, 80), result.defeated ? 0.008 : 0.005); + } + + this.showCompactGrowthMapResult(result.attacker, result.characterGrowth, result.attackerGrowth); + await Promise.all([this.delay(duration + 20), actorFramePromise]); + if (attackerView) { + this.syncUnitMotion(result.attacker, attackerView); + } + objects.forEach((object) => object.active && object.destroy()); + } + + private async playSupportMapPresentation(result: SupportResult) { + this.hideCombatCutIn(); + const userView = this.unitViews.get(result.user.id); + const targetView = this.unitViews.get(result.target.id); + const targetX = targetView?.sprite.x ?? this.tileCenterX(result.target.x); + const targetY = targetView?.sprite.y ?? this.tileCenterY(result.target.y); + const accent = result.usable.effect === 'heal' ? 0x59d18c : 0xd8b15f; + const duration = this.scaledBattleDuration(240, 100); + const objects: Phaser.GameObjects.GameObject[] = []; + let userFramePromise: Promise = Promise.resolve(); + + if (userView?.sprite.active && userView.sprite.visible) { + userFramePromise = this.playUnitActionFrames( + userView.sprite, + result.user, + userView.direction, + result.usable.command === 'strategy' ? 'strategy' : 'item', + 42, + 1 + ); + this.tweens.add({ + targets: userView.sprite, + scaleX: userView.baseScaleX * 1.08, + scaleY: userView.baseScaleY * 1.08, + duration: Math.max(55, Math.round(duration * 0.42)), + yoyo: true, + ease: 'Sine.easeOut' + }); + } + + soundDirector.playEffect(result.usable.command === 'strategy' ? 'strategy-cast' : 'exp-gain', { + volume: result.usable.command === 'strategy' ? 0.3 : 0.24, + stopAfterMs: 620 + }); + const ring = this.add.circle(targetX, targetY - this.layout.tileSize * 0.18, this.layout.tileSize * 0.3); + ring.setStrokeStyle(4, accent, 0.92); + ring.setDepth(12.8); + if (this.mapMask) { + ring.setMask(this.mapMask); + } + objects.push(ring); + const spark = this.add.star(targetX, targetY - this.layout.tileSize * 0.18, 6, 7, this.layout.tileSize * 0.35, accent, 0.86); + spark.setDepth(12.7); + if (this.mapMask) { + spark.setMask(this.mapMask); + } + objects.push(spark); + const badge = this.add.text(targetX, targetY - this.layout.tileSize * 0.9, result.usable.name, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: this.battleUiFontSize(13), + color: result.usable.effect === 'heal' ? '#a8ffd0' : '#fff2b8', + fontStyle: '700', + backgroundColor: 'rgba(7, 13, 19, 0.88)', + padding: { x: this.battleUiLength(8), y: this.battleUiLength(4) }, + stroke: '#071018', + strokeThickness: this.battleUiLength(2) + }); + badge.setOrigin(0.5); + badge.setDepth(13); + if (this.mapMask) { + badge.setMask(this.mapMask); + } + objects.push(badge); + + this.tweens.add({ targets: ring, scale: 1.8, alpha: 0, duration, ease: 'Sine.easeOut' }); + this.tweens.add({ targets: spark, scale: 1.25, angle: 180, alpha: 0, duration, ease: 'Sine.easeOut' }); + this.tweens.add({ targets: badge, y: badge.y - this.battleUiLength(10), alpha: 0, delay: Math.round(duration * 0.45), duration: Math.round(duration * 0.55) }); + this.showCompactGrowthMapResult(result.user, result.characterGrowth, result.equipmentGrowth); + await Promise.all([this.delay(duration + 20), userFramePromise]); + if (userView) { + this.syncUnitMotion(result.user, userView); + } + objects.forEach((object) => object.active && object.destroy()); + } + + private showCompactGrowthMapResult( + unit: UnitData, + characterGrowth: CharacterGrowthResult, + equipmentGrowth: EquipmentGrowthResult + ) { + const lines = [ + characterGrowth.leveled ? `LEVEL UP · Lv ${characterGrowth.level}` : '', + equipmentGrowth.leveled ? `${equipmentGrowth.itemName} · Lv ${equipmentGrowth.level}` : '' + ].filter(Boolean); + if (lines.length === 0) { + return; + } + this.showMapResultPopup(unit, lines, '#a8ffd0', '#072416', 16, lines.length > 1 ? 34 : 26); + soundDirector.playGrowthTick(); + } + private async playCombatCutIn(result: CombatResult) { this.hideCombatCutIn(); @@ -25402,27 +25755,15 @@ export class BattleScene extends Phaser.Scene { } private equipmentAttackBonus(unit: UnitData) { - return equipmentSlots.reduce((total, slot) => { - const item = getItem(unit.equipment[slot].itemId); - const levelBonus = slot === 'weapon' ? unit.equipment[slot].level - 1 : 0; - return total + (item.attackBonus ?? 0) + levelBonus; - }, 0); + return calculateEquipmentBonuses(unit.equipment).attack; } private equipmentStrategyBonus(unit: UnitData) { - return equipmentSlots.reduce((total, slot) => { - const item = getItem(unit.equipment[slot].itemId); - const levelBonus = slot === 'accessory' ? unit.equipment[slot].level - 1 : 0; - return total + (item.strategyBonus ?? 0) + levelBonus; - }, 0); + return calculateEquipmentBonuses(unit.equipment).strategy; } private equipmentDefenseBonus(unit: UnitData) { - return equipmentSlots.reduce((total, slot) => { - const item = getItem(unit.equipment[slot].itemId); - const levelBonus = slot === 'armor' ? unit.equipment[slot].level - 1 : 0; - return total + (item.defenseBonus ?? 0) + levelBonus; - }, 0); + return calculateEquipmentBonuses(unit.equipment).defense; } private renderCompactValueBox(x: number, y: number, width: number, icon: BattleUiIconKey, label: string, value: string) { @@ -26297,6 +26638,12 @@ export class BattleScene extends Phaser.Scene { firstBattleTutorial: this.firstBattleTutorialDebugState(), combatCutIn: combatCutInDebug, combatCutInStage: combatCutInDebug, + combatPresentation: { + mode: this.combatPresentationMode, + label: combatPresentationModeLabel(this.combatPresentationMode), + counts: { ...this.combatPresentationCount }, + last: this.combatPresentationLast ? { ...this.combatPresentationLast } : null + }, sortieDoctrine: this.debugSortieDoctrine(), firstSortieRoleEffects: this.debugFirstPursuitRoleEffects(), firstSortieRoleMechanicsProbe: this.debugFirstPursuitRoleMechanicsProbe(), diff --git a/src/game/scenes/CampScene.ts b/src/game/scenes/CampScene.ts index 970885b..11cf518 100644 --- a/src/game/scenes/CampScene.ts +++ b/src/game/scenes/CampScene.ts @@ -4,6 +4,7 @@ import { soundDirector } from '../audio/SoundDirector'; import { battleUiIconFrames, battleUiIconTextureForSize, loadBattleUiIcons, type BattleUiIconKey } from '../data/battleUiIcons'; import { selectCampSkin, type CampSkinSelection } from '../data/campSkins'; import { + calculateEquipmentBonuses, equipmentExpToNext, equipmentSlotLabels, equipmentSlots, @@ -23233,15 +23234,15 @@ export class CampScene extends Phaser.Scene { } private equipmentAttackBonus(unit: UnitData) { - return equipmentSlots.reduce((sum, slot) => sum + (getItem(unit.equipment[slot].itemId).attackBonus ?? 0), 0); + return calculateEquipmentBonuses(unit.equipment).attack; } private equipmentStrategyBonus(unit: UnitData) { - return equipmentSlots.reduce((sum, slot) => sum + (getItem(unit.equipment[slot].itemId).strategyBonus ?? 0), 0); + return calculateEquipmentBonuses(unit.equipment).strategy; } private equipmentDefenseBonus(unit: UnitData) { - return equipmentSlots.reduce((sum, slot) => sum + (getItem(unit.equipment[slot].itemId).defenseBonus ?? 0), 0); + return calculateEquipmentBonuses(unit.equipment).defense; } private wolongClueCount() { diff --git a/src/game/scenes/StoryScene.ts b/src/game/scenes/StoryScene.ts index da555b9..8a5b98c 100644 --- a/src/game/scenes/StoryScene.ts +++ b/src/game/scenes/StoryScene.ts @@ -5,6 +5,7 @@ import { battleUiIconFrames, battleUiIconMicroTextureKey, battleUiIconTextureKey, + loadBattleUiIcons, type BattleUiIconKey } from '../data/battleUiIcons'; import { portraitAssetEntriesForKey, portraitKeyForSpeaker, type PortraitAssetEntry } from '../data/portraitAssets'; @@ -26,7 +27,7 @@ import { import { prologuePages, type PortraitKey, type StoryPage } from '../data/scenario'; import { campaignVictoryRewardPresentation, getFirstBattleReport } from '../state/campaignState'; import { palette } from '../ui/palette'; -import { startGameScene } from './lazyScenes'; +import { ensureLazyScene, startGameScene } from './lazyScenes'; type AlphaObject = Phaser.GameObjects.Container | Phaser.GameObjects.Image | Phaser.GameObjects.Rectangle | Phaser.GameObjects.Text; @@ -120,6 +121,11 @@ const firstBattleVictoryPageIds = new Set([ 'first-victory-next' ]); const firstBattleId = 'first-battle-zhuo-commandery'; +const firstBattleWarmupUnitTextureKeys = [ + 'unit-rebel', + 'unit-rebel-archer', + 'unit-rebel-cavalry' +] as const; export class StoryScene extends Phaser.Scene { private pageIndex = 0; @@ -250,6 +256,7 @@ export class StoryScene extends Phaser.Scene { } this.pageLoadingText = undefined; }); + this.warmFirstBattleSceneModule(); this.ensureStoryPageAssets(0, () => { if (generation !== this.storyAssetLoadGeneration) { @@ -348,20 +355,31 @@ export class StoryScene extends Phaser.Scene { this.processStoryPageAssetQueue(); }; + const finishUnitBases = () => { + if (generation !== this.storyAssetLoadGeneration) { + return; + } + if (this.isFirstBattleWarmupPage(request.pageIndex)) { + loadBattleUiIcons(this, finishRequest); + return; + } + finishRequest(); + }; + const loadUnitBases = () => { if (generation !== this.storyAssetLoadGeneration || plan.unitTextureKeys.length === 0) { - finishRequest(); + finishUnitBases(); return; } try { - loadUnitBaseSheets(this, plan.unitTextureKeys, finishRequest); + loadUnitBaseSheets(this, plan.unitTextureKeys, finishUnitBases); } catch (error) { console.warn(`Failed to prepare story unit bases for page ${request.pageIndex}`, error); plan.unitTextureKeys .filter((key) => !this.textures.exists(key)) .forEach((key) => this.storyAssetFailures.add(key)); - finishRequest(); + finishUnitBases(); } }; @@ -416,13 +434,39 @@ export class StoryScene extends Phaser.Scene { .filter((entry): entry is PortraitAssetEntry => entry !== undefined) .filter((entry, index, entries) => entries.findIndex((candidate) => candidate.textureKey === entry.textureKey) === index); + const warmupUnitTextureKeys = this.isFirstBattleWarmupPage(pageIndex) + ? firstBattleWarmupUnitTextureKeys + : []; return { backgrounds: backgroundKey && backgroundUrl ? [{ key: backgroundKey, url: backgroundUrl }] : [], portraits, - unitTextureKeys: Array.from(new Set(storyCutsceneActorTextureKeys(page.cutscene))) + unitTextureKeys: Array.from(new Set([ + ...storyCutsceneActorTextureKeys(page.cutscene), + ...warmupUnitTextureKeys + ])) }; } + private isFirstBattlePrelude() { + const requestedBattleId = typeof this.nextSceneData?.battleId === 'string' + ? this.nextSceneData.battleId + : firstBattleId; + return this.nextScene === 'BattleScene' && requestedBattleId === firstBattleId; + } + + private isFirstBattleWarmupPage(pageIndex: number) { + return this.isFirstBattlePrelude() && pageIndex === Math.min(1, this.pages.length - 1); + } + + private warmFirstBattleSceneModule() { + if (!this.isFirstBattlePrelude()) { + return; + } + void ensureLazyScene(this.game, 'BattleScene').catch((error) => { + console.warn('Failed to warm the first battle scene module.', error); + }); + } + private prefetchNextStoryPage(pageIndex: number) { const nextPageIndex = pageIndex + 1; if (nextPageIndex < this.pages.length) { diff --git a/src/game/scenes/TitleScene.ts b/src/game/scenes/TitleScene.ts index 8a8e033..6dbce67 100644 --- a/src/game/scenes/TitleScene.ts +++ b/src/game/scenes/TitleScene.ts @@ -1,5 +1,11 @@ import Phaser from 'phaser'; import { soundDirector } from '../audio/SoundDirector'; +import { + combatPresentationModeLabel, + loadCombatPresentationMode, + nextCombatPresentationMode, + saveCombatPresentationMode +} from '../settings/combatPresentation'; import { battleIdForCampaignStep, isCampCampaignStep } from '../state/campaignRouting'; import { hasCampaignSave, @@ -371,10 +377,10 @@ export class TitleScene extends Phaser.Scene { this.closeSaveSlotPanel(); const panel = this.add.container(x, y + ui(238)); - const backdrop = this.add.rectangle(0, 0, ui(286), ui(204), 0x0e151d, 0.9); + const backdrop = this.add.rectangle(0, 0, ui(286), ui(250), 0x0e151d, 0.9); backdrop.setStrokeStyle(ui(1), palette.gold, 0.52); - const title = this.add.text(0, -ui(74), '설정', { + const title = this.add.text(0, -ui(96), '설정', { fontSize: uiPx(24), color: '#f1e3c2', fontStyle: '700', @@ -383,20 +389,28 @@ export class TitleScene extends Phaser.Scene { }); title.setOrigin(0.5); - const soundText = this.createSettingsButton(0, -ui(16), this.soundLabel()); + const soundText = this.createSettingsButton(0, -ui(38), this.soundLabel()); soundText.on('pointerdown', () => { soundDirector.setMuted(!soundDirector.isMuted()); soundText.setText(this.soundLabel()); soundDirector.playSelect(); }); - const close = this.createSettingsButton(0, ui(42), '닫기'); + const presentationText = this.createSettingsButton(0, ui(16), this.combatPresentationLabel()); + presentationText.on('pointerdown', () => { + const nextMode = nextCombatPresentationMode(loadCombatPresentationMode()); + saveCombatPresentationMode(nextMode); + presentationText.setText(this.combatPresentationLabel()); + soundDirector.playSelect(); + }); + + const close = this.createSettingsButton(0, ui(68), '닫기'); close.on('pointerdown', () => { soundDirector.playSelect(); this.closeSettingsPanel(); }); - const hint = this.add.text(0, ui(78), 'Esc 닫기', { + const hint = this.add.text(0, ui(104), '연출 항목을 눌러 단계 변경 · Esc 닫기', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: uiPx(11), color: '#9fb0bf', @@ -406,7 +420,7 @@ export class TitleScene extends Phaser.Scene { }); hint.setOrigin(0.5); - panel.add([backdrop, title, soundText, close, hint]); + panel.add([backdrop, title, soundText, presentationText, close, hint]); panel.setAlpha(0); this.settingsPanel = panel; this.tweens.add({ targets: panel, alpha: 1, y: y + ui(228), duration: 160, ease: 'Sine.easeOut' }); @@ -432,6 +446,10 @@ export class TitleScene extends Phaser.Scene { return `음향: ${soundDirector.isMuted() ? '꺼짐' : '켜짐'}`; } + private combatPresentationLabel() { + return `전투 연출: ${combatPresentationModeLabel(loadCombatPresentationMode())}`; + } + private closeSettingsPanel() { this.settingsPanel?.destroy(); this.settingsPanel = undefined; diff --git a/src/game/scenes/lazyScenes.ts b/src/game/scenes/lazyScenes.ts index bf48906..9430d6a 100644 --- a/src/game/scenes/lazyScenes.ts +++ b/src/game/scenes/lazyScenes.ts @@ -34,7 +34,13 @@ export async function ensureLazyScene(game: Phaser.Game, key: LazySceneKey) { } }); pendingSceneLoads.set(key, load); - await load; + try { + await load; + } finally { + if (pendingSceneLoads.get(key) === load) { + pendingSceneLoads.delete(key); + } + } } export async function startLazyScene(scene: Phaser.Scene, key: LazySceneKey, data?: Record) { diff --git a/src/game/settings/combatPresentation.ts b/src/game/settings/combatPresentation.ts new file mode 100644 index 0000000..751b7e9 --- /dev/null +++ b/src/game/settings/combatPresentation.ts @@ -0,0 +1,109 @@ +export const combatPresentationStorageKey = 'heros-web:combat-presentation-mode'; + +export type CombatPresentationMode = 'always' | 'important' | 'map'; + +export type CombatPresentationImportance = { + signature?: boolean; + critical?: boolean; + defeated?: boolean; + resonance?: boolean; + growth?: boolean; +}; + +type StorageLike = Pick; + +export const combatPresentationModes: readonly CombatPresentationMode[] = ['always', 'important', 'map']; + +export const combatPresentationModeLabels: Record = { + always: '항상', + important: '중요 장면만', + map: '지도 연출' +}; + +export function isCombatPresentationMode(value: unknown): value is CombatPresentationMode { + return typeof value === 'string' && combatPresentationModes.includes(value as CombatPresentationMode); +} + +export function nextCombatPresentationMode(mode: CombatPresentationMode): CombatPresentationMode { + const index = combatPresentationModes.indexOf(mode); + return combatPresentationModes[(index + 1) % combatPresentationModes.length]; +} + +export function combatPresentationModeLabel(mode: CombatPresentationMode) { + return combatPresentationModeLabels[mode]; +} + +export function shouldUseFullCombatCutIn( + mode: CombatPresentationMode, + importance: CombatPresentationImportance +) { + if (mode === 'always') { + return true; + } + if (mode === 'map') { + return false; + } + return Boolean( + importance.signature || + importance.critical || + importance.defeated || + importance.resonance || + importance.growth + ); +} + +export function combatPresentationImportanceReason(importance: CombatPresentationImportance) { + if (importance.signature) { + return '고유 전법'; + } + if (importance.defeated) { + return '격파'; + } + if (importance.critical) { + return '치명타'; + } + if (importance.resonance) { + return '공명'; + } + if (importance.growth) { + return '레벨 상승'; + } + return '일반 행동'; +} + +export function loadCombatPresentationMode(storage = resolveStorage()): CombatPresentationMode { + if (!storage) { + return 'important'; + } + try { + const value = storage.getItem(combatPresentationStorageKey); + return isCombatPresentationMode(value) ? value : 'important'; + } catch { + return 'important'; + } +} + +export function saveCombatPresentationMode( + mode: CombatPresentationMode, + storage = resolveStorage() +) { + if (!storage) { + return; + } + try { + storage.setItem(combatPresentationStorageKey, mode); + } catch { + // Local storage can be unavailable in private or restricted browser contexts. + } +} + +function resolveStorage(): StorageLike | undefined { + if (typeof window === 'undefined') { + return undefined; + } + try { + return window.localStorage; + } catch { + return undefined; + } +} diff --git a/src/main.ts b/src/main.ts index c939a21..01a45bf 100644 --- a/src/main.ts +++ b/src/main.ts @@ -79,8 +79,12 @@ function createDebugApi(game: Phaser.Game): HerosDebugApi { game, activeScenes: () => game.scene.getScenes(true).map((activeScene) => activeScene.scene.key), audio: () => soundDirector.getDebugState(), - battle: () => battleScene()?.getDebugState?.() ?? { active: false, reason: 'BattleScene is not active yet.' }, - camp: () => campScene()?.getDebugState?.() ?? { active: false, reason: 'CampScene is not active yet.' }, + battle: () => game.scene.isActive('BattleScene') + ? battleScene()?.getDebugState?.() ?? { active: false, reason: 'BattleScene debug state is unavailable.' } + : { active: false, reason: 'BattleScene is not active yet.' }, + camp: () => game.scene.isActive('CampScene') + ? campScene()?.getDebugState?.() ?? { active: false, reason: 'CampScene debug state is unavailable.' } + : { active: false, reason: 'CampScene is not active yet.' }, goToBattle: (battleId) => startLazySceneFromGame(game, 'BattleScene', battleId ? { battleId } : undefined), goToCamp: () => startLazySceneFromGame(game, 'CampScene'), forceBattleOutcome: (outcome) => {