feat: improve combat feedback and progression
This commit is contained in:
@@ -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",
|
||||
|
||||
58
scripts/verify-combat-presentation-settings.mjs
Normal file
58
scripts/verify-combat-presentation-settings.mjs
Normal file
@@ -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();
|
||||
}
|
||||
324
scripts/verify-enemy-intent-rollout.mjs
Normal file
324
scripts/verify-enemy-intent-rollout.mjs
Normal file
@@ -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));
|
||||
}
|
||||
164
scripts/verify-growth-consistency.mjs
Normal file
164
scripts/verify-growth-consistency.mjs
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -22,6 +22,12 @@ export type ItemDefinition = {
|
||||
effects: string[];
|
||||
};
|
||||
|
||||
export type EquipmentBonuses = {
|
||||
attack: number;
|
||||
defense: number;
|
||||
strategy: number;
|
||||
};
|
||||
|
||||
export const equipmentSlotLabels: Record<EquipmentSlot, string> = {
|
||||
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<EquipmentBonuses>(
|
||||
(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 }
|
||||
);
|
||||
}
|
||||
|
||||
43
src/game/data/enemyIntent.ts
Normal file
43
src/game/data/enemyIntent.ts
Normal file
@@ -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;
|
||||
}
|
||||
55
src/game/data/unitProgress.ts
Normal file
55
src/game/data/unitProgress.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import type { UnitClassKey } from './battleRules';
|
||||
import type { UnitData, UnitStats } from './scenario';
|
||||
|
||||
const levelUpStatKeysByClass: Record<UnitClassKey, Array<keyof UnitStats>> = {
|
||||
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<keyof UnitStats>).reduce<UnitStats>((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 }
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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<Record<MapMenuAction, string>> = {
|
||||
bond: '공명',
|
||||
situation: '전황',
|
||||
speed: '속도',
|
||||
presentation: '전투 연출',
|
||||
bgm: 'BGM',
|
||||
close: '닫기'
|
||||
};
|
||||
@@ -3515,6 +3542,9 @@ export class BattleScene extends Phaser.Scene {
|
||||
private mapMenuObjects: Array<Phaser.GameObjects.Rectangle | Phaser.GameObjects.Text> = [];
|
||||
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<string, { target: UnitData; immediate: number; approach: number }>();
|
||||
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<void> = 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<void> = 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(),
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string, unknown>) {
|
||||
|
||||
109
src/game/settings/combatPresentation.ts
Normal file
109
src/game/settings/combatPresentation.ts
Normal file
@@ -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<Storage, 'getItem' | 'setItem'>;
|
||||
|
||||
export const combatPresentationModes: readonly CombatPresentationMode[] = ['always', 'important', 'map'];
|
||||
|
||||
export const combatPresentationModeLabels: Record<CombatPresentationMode, string> = {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user