feat: make camp preparation affect first battle
This commit is contained in:
@@ -12,6 +12,32 @@ export const prologueMilitiaCampRequiredObjectiveIds = [
|
||||
export type PrologueMilitiaCampRequiredObjectiveId =
|
||||
(typeof prologueMilitiaCampRequiredObjectiveIds)[number];
|
||||
|
||||
export const prologueMilitiaCampPreparationPayoffs = [
|
||||
{
|
||||
objectiveId: 'review-scout-report',
|
||||
worldCue: '작전판에 적 진형과 피난로 표식',
|
||||
battleCallback: 'enemy-intel',
|
||||
battleLabel: '배치 중 적 의도·예상 표적 공개'
|
||||
},
|
||||
{
|
||||
objectiveId: 'ready-vanguard',
|
||||
worldCue: '훈련장 의병 대열 정렬',
|
||||
battleCallback: 'deployment-flex',
|
||||
battleLabel: '권장 전열과 예비 시작칸 2곳'
|
||||
},
|
||||
{
|
||||
objectiveId: 'inspect-arms',
|
||||
worldCue: '보급 수레에 물·상처약 적재',
|
||||
battleCallback: 'field-salve',
|
||||
battleLabel: '관우에게 상처약 1개 지급'
|
||||
}
|
||||
] as const satisfies ReadonlyArray<{
|
||||
objectiveId: PrologueMilitiaCampRequiredObjectiveId;
|
||||
worldCue: string;
|
||||
battleCallback: 'enemy-intel' | 'deployment-flex' | 'field-salve';
|
||||
battleLabel: string;
|
||||
}>;
|
||||
|
||||
export type PrologueMilitiaCampCommandChoice = {
|
||||
orderId: SortieOrderId;
|
||||
label: string;
|
||||
@@ -38,10 +64,6 @@ export const prologueMilitiaCampCommandChoices: readonly PrologueMilitiaCampComm
|
||||
speaker: '장비',
|
||||
textureKey: 'unit-zhang-fei',
|
||||
text: '좋소! 앞장서되 혼자 깊이 들지는 않겠소. 모두 함께 돌아오는 첫 승리를 만들겠소!'
|
||||
},
|
||||
{
|
||||
speaker: '군령',
|
||||
text: '정예 군령이 기록되었다. 첫 전투에서 전원 생존과 높은 편성 평가를 달성하면 추가 전공을 얻는다.'
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -61,10 +83,6 @@ export const prologueMilitiaCampCommandChoices: readonly PrologueMilitiaCampComm
|
||||
speaker: '관우',
|
||||
textureKey: 'unit-guan-yu',
|
||||
text: '속도에 휩쓸려 대열이 갈라지지 않도록 중앙에서 장비의 돌파를 받치겠습니다.'
|
||||
},
|
||||
{
|
||||
speaker: '군령',
|
||||
text: '기동 군령이 기록되었다. 첫 전투에서 제한 턴과 돌파 기여 조건을 달성하면 추가 전공을 얻는다.'
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -84,10 +102,6 @@ export const prologueMilitiaCampCommandChoices: readonly PrologueMilitiaCampComm
|
||||
speaker: '장비',
|
||||
textureKey: 'unit-zhang-fei',
|
||||
text: '좋소! 관우가 전열을 열면 내가 측면을 걷어 내 형님의 후원이 닿게 하겠소!'
|
||||
},
|
||||
{
|
||||
speaker: '군령',
|
||||
text: '거점 군령이 기록되었다. 첫 전투에서 마을 목표와 전열·후원 기여를 달성하면 추가 전공을 얻는다.'
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -159,12 +173,7 @@ export const prologueMilitiaCampNpcDefinitions: readonly PrologueMilitiaCampNpcD
|
||||
{
|
||||
speaker: '유비',
|
||||
textureKey: 'unit-liu-bei',
|
||||
text: '피난민을 먼저 서쪽 샛길로 들이고 우물과 수레가 있는 길은 비워 둡시다. 정확한 적 진형은 출전 직전 작전판에 옮겨 주시오.'
|
||||
},
|
||||
{
|
||||
speaker: '관우',
|
||||
textureKey: 'unit-guan-yu',
|
||||
text: '보병과 궁병, 두령의 위치를 표식으로 정리하겠습니다. 저는 정찰대를 이끌어 마을 어귀를 계속 살피겠습니다.'
|
||||
text: '피난민은 서쪽 샛길로 들이고 우물과 수레 길은 비워 둡시다. 보병과 궁병, 두령의 위치는 작전판에 옮겨 주시오.'
|
||||
}
|
||||
],
|
||||
repeatDialogue: [
|
||||
@@ -194,11 +203,6 @@ export const prologueMilitiaCampNpcDefinitions: readonly PrologueMilitiaCampNpcD
|
||||
speaker: '유비',
|
||||
textureKey: 'unit-liu-bei',
|
||||
text: '열 명씩 조를 나누고, 앞줄이 멈추면 뒷줄도 함께 멈추는 구령을 정합시다. 공을 다투느라 대열을 깨서는 안 되오.'
|
||||
},
|
||||
{
|
||||
speaker: '장비',
|
||||
textureKey: 'unit-zhang-fei',
|
||||
text: '좋소! 진군과 정지, 재집결 신호를 몸에 익히게 하겠소. 북문에 모일 때까지 누구도 제멋대로 달려나가지 못하게 하겠소!'
|
||||
}
|
||||
],
|
||||
repeatDialogue: [
|
||||
@@ -222,22 +226,12 @@ export const prologueMilitiaCampNpcDefinitions: readonly PrologueMilitiaCampNpcD
|
||||
{
|
||||
speaker: '의용군 군수관',
|
||||
textureKey: 'unit-shu-officer',
|
||||
text: '중산 상인 장세평과 소쌍이 말과 쇠를 보탰습니다. 대장간에서는 유비 공의 자웅일대검, 관우 공의 청룡언월도, 장비 공의 장팔사모를 벼려 냈습니다.'
|
||||
},
|
||||
{
|
||||
speaker: '의용군 군수관',
|
||||
textureKey: 'unit-shu-officer',
|
||||
text: '장비 공이 장원 창고도 열어 주었습니다. 콩과 마른 곡식, 상처에 쓸 천까지 조마다 나누어 두었습니다.'
|
||||
text: '장세평과 소쌍이 말과 쇠를 보태 자웅일대검·청룡언월도·장팔사모를 벼렸고, 장비 공이 연 창고의 콩·마른 곡식·상처에 쓸 천도 조마다 나누었습니다.'
|
||||
},
|
||||
{
|
||||
speaker: '유비',
|
||||
textureKey: 'unit-liu-bei',
|
||||
text: '무기보다 물과 퇴로부터 다시 확인해 주시오. 보급은 채울 수 있어도 사람의 목숨은 다시 채울 수 없소.'
|
||||
},
|
||||
{
|
||||
speaker: '의용군 군수관',
|
||||
textureKey: 'unit-shu-officer',
|
||||
text: '명심하겠습니다. 후송 수레는 남쪽 길에 세우고, 각 조에 상처약을 나누겠습니다.'
|
||||
text: '무기보다 물과 퇴로가 먼저요. 후송 수레는 남쪽 길에 세우고 상처약을 각 조에 나누어, 누구도 홀로 남지 않게 해 주시오.'
|
||||
}
|
||||
],
|
||||
repeatDialogue: [
|
||||
@@ -261,7 +255,7 @@ export const prologueMilitiaCampNpcDefinitions: readonly PrologueMilitiaCampNpcD
|
||||
{
|
||||
speaker: '추정',
|
||||
textureKey: 'unit-shu-officer',
|
||||
text: '정찰과 대열, 보급까지 갖추었군. 황건 두령 한석이 북동쪽에서 마을을 노린다. 그대들이 선봉을 맡되, 첫 승리를 어떤 군령으로 이끌지 직접 정하게.'
|
||||
text: '막사가 잘 갖춰졌군. 황건 두령 한석이 북동쪽에서 마을을 노린다. 그대들이 선봉을 맡되, 첫 승리를 어떤 군령으로 이끌지 직접 정하게.'
|
||||
}
|
||||
],
|
||||
lockedDialogue: [
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
} from '../data/campaignPresentationProfiles';
|
||||
import { getSortieFlow } from '../data/campaignFlow';
|
||||
import { findCityStayBeforeBattle } from '../data/cityStays';
|
||||
import { prologueMilitiaCampPreparationPayoffs } from '../data/prologueMilitiaCamp';
|
||||
import {
|
||||
enemyIntentOpeningGuide,
|
||||
isEnemyIntentCounterplayAvailable,
|
||||
@@ -140,6 +141,7 @@ import {
|
||||
markCampaignStep,
|
||||
normalizeCampaignSortieRecommendationSnapshot,
|
||||
normalizeCampaignSortieItemAssignments,
|
||||
prologueMilitiaCampCampaignTutorialIds,
|
||||
readCampaignSaveState,
|
||||
saveCampaignState,
|
||||
setFirstBattleReport,
|
||||
@@ -190,6 +192,20 @@ const battleEnvironmentHazeDepth = 3.12;
|
||||
const battleEnvironmentParticleDepth = 3.3;
|
||||
const firstBattleTutorialId: CampaignTutorialId = 'first-battle-basic-controls';
|
||||
|
||||
type FirstBattlePreparationState = {
|
||||
eligible: boolean;
|
||||
scouting: boolean;
|
||||
vanguard: boolean;
|
||||
supplies: boolean;
|
||||
};
|
||||
|
||||
const emptyFirstBattlePreparationState: FirstBattlePreparationState = {
|
||||
eligible: false,
|
||||
scouting: false,
|
||||
vanguard: false,
|
||||
supplies: false
|
||||
};
|
||||
|
||||
const unitTexture: Record<string, string> = {
|
||||
'liu-bei': 'unit-liu-bei',
|
||||
'guan-yu': 'unit-guan-yu',
|
||||
@@ -1345,6 +1361,8 @@ type RosterPanelLayout = {
|
||||
type DeploymentPanelLayout = {
|
||||
headerBounds: { x: number; y: number; width: number; height: number };
|
||||
statusBounds: { x: number; y: number; width: number; height: number };
|
||||
subtitleText: string;
|
||||
preparationSummaryBounds: { x: number; y: number; width: number; height: number } | null;
|
||||
changeCount: number;
|
||||
recommended: boolean;
|
||||
noticeBounds: { x: number; y: number; width: number; height: number };
|
||||
@@ -3859,6 +3877,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
private launchSortieOrderId: SortieOrderId = 'elite';
|
||||
private launchSortieResonanceBondId?: string;
|
||||
private launchSortieRecommendation?: CampaignSortieRecommendationSnapshot;
|
||||
private firstBattlePreparation: FirstBattlePreparationState = { ...emptyFirstBattlePreparationState };
|
||||
private coreResonancePursuitAttempts = 0;
|
||||
private coreResonancePursuitSuccesses = 0;
|
||||
private coreResonancePursuitFailures = 0;
|
||||
@@ -3962,6 +3981,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
this.firstBattleTutorialCard = undefined;
|
||||
this.firstBattleTutorialSkipButton = undefined;
|
||||
const campaign = getCampaignState();
|
||||
this.firstBattlePreparation = this.resolveFirstBattlePreparation(campaign);
|
||||
const selectedOrderId = campaign.sortieOrderSelection?.battleId === battleScenario.id
|
||||
? campaign.sortieOrderSelection.orderId
|
||||
: undefined;
|
||||
@@ -8372,6 +8392,51 @@ export class BattleScene extends Phaser.Scene {
|
||||
this.clearPointerFeedback();
|
||||
}
|
||||
|
||||
private resolveFirstBattlePreparation(campaign: CampaignState): FirstBattlePreparationState {
|
||||
if (battleScenario.id !== 'first-battle-zhuo-commandery') {
|
||||
return { ...emptyFirstBattlePreparationState };
|
||||
}
|
||||
const completed = new Set(campaign.completedTutorialIds);
|
||||
return {
|
||||
eligible: true,
|
||||
scouting: completed.has(prologueMilitiaCampCampaignTutorialIds.reviewScoutReport),
|
||||
vanguard: completed.has(prologueMilitiaCampCampaignTutorialIds.readyVanguard),
|
||||
supplies: completed.has(prologueMilitiaCampCampaignTutorialIds.inspectArms)
|
||||
};
|
||||
}
|
||||
|
||||
private firstBattlePreparationCompletedCount() {
|
||||
const preparation = this.firstBattlePreparation;
|
||||
return [preparation.scouting, preparation.vanguard, preparation.supplies]
|
||||
.filter(Boolean)
|
||||
.length;
|
||||
}
|
||||
|
||||
private firstBattlePreparationSummary() {
|
||||
if (!this.firstBattlePreparation.eligible) {
|
||||
return undefined;
|
||||
}
|
||||
const effects = [
|
||||
this.firstBattlePreparation.scouting ? '적 의도 공개' : '',
|
||||
this.firstBattlePreparation.vanguard ? '예비 배치 +2' : '',
|
||||
this.firstBattlePreparation.supplies ? '관우 상처약 +1' : ''
|
||||
].filter(Boolean);
|
||||
const count = this.firstBattlePreparationCompletedCount();
|
||||
return effects.length > 0
|
||||
? `막사 준비 ${count}/3 반영 · ${effects.join(' · ')}`
|
||||
: '막사 준비 미완료 · 기본 전열로 출전';
|
||||
}
|
||||
|
||||
private firstBattleScoutBrief() {
|
||||
if (!this.firstBattlePreparation.scouting) {
|
||||
return undefined;
|
||||
}
|
||||
const enemies = battleUnits.filter((unit) => unit.faction === 'enemy' && unit.hp > 0);
|
||||
const archers = enemies.filter((unit) => unit.classKey === 'archer').length;
|
||||
const cavalry = enemies.filter((unit) => unit.classKey === 'cavalry').length;
|
||||
return `관우 정찰: 적 ${enemies.length}부대 · 궁병 ${archers} · 기병 ${cavalry} · 두령은 북동쪽`;
|
||||
}
|
||||
|
||||
private shouldShowPreBattleDeployment() {
|
||||
return Boolean(battleScenario.sortie?.deploymentSlots?.length) && this.deploymentEligibleUnits().length > 0;
|
||||
}
|
||||
@@ -8396,6 +8461,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
this.centerCameraOnTile(center.x, center.y);
|
||||
this.renderDeploymentMap();
|
||||
this.renderDeploymentPanel();
|
||||
this.refreshEnemyIntentForecast();
|
||||
this.refreshUnitLegibilityStyles();
|
||||
this.updatePointerFeedback(this.input.activePointer, true);
|
||||
}
|
||||
@@ -8410,6 +8476,10 @@ export class BattleScene extends Phaser.Scene {
|
||||
}
|
||||
|
||||
private deploymentDefaultNotice() {
|
||||
const scoutBrief = this.firstBattleScoutBrief();
|
||||
if (scoutBrief) {
|
||||
return `${scoutBrief}. 적 표식을 보며 시작 위치를 조정하세요.`;
|
||||
}
|
||||
return '추천 전열이 적용되어 있습니다. 장수를 선택한 뒤 시작 구역 안에서 위치를 바꿀 수 있습니다.';
|
||||
}
|
||||
|
||||
@@ -8423,6 +8493,16 @@ export class BattleScene extends Phaser.Scene {
|
||||
}
|
||||
|
||||
private deploymentSubtitle() {
|
||||
const preparationSummary = this.firstBattlePreparationSummary();
|
||||
if (preparationSummary && this.firstBattlePreparationCompletedCount() > 0) {
|
||||
const strategySummary = this.launchSortieRecommendation
|
||||
? `선택 전략 · ${this.launchSortieRecommendation.label} · ${this.launchSortieRecommendation.summary}`
|
||||
: (() => {
|
||||
const order = sortieOrderDefinition(this.launchSortieOrderId);
|
||||
return `첫 군령 · ${order.label} · ${order.summary}`;
|
||||
})();
|
||||
return `${preparationSummary}\n${strategySummary}`;
|
||||
}
|
||||
if (this.launchSortieRecommendation) {
|
||||
return `선택 전략 · ${this.launchSortieRecommendation.label} · ${this.launchSortieRecommendation.summary}`;
|
||||
}
|
||||
@@ -8549,9 +8629,13 @@ export class BattleScene extends Phaser.Scene {
|
||||
}
|
||||
}
|
||||
|
||||
const defaultReserveLimit = Math.max(2, Math.min(4, this.deploymentEligibleUnits().length + 1));
|
||||
const reserveLimit = this.firstBattlePreparation.eligible && !this.firstBattlePreparation.vanguard
|
||||
? Math.min(2, defaultReserveLimit)
|
||||
: defaultReserveLimit;
|
||||
return candidates
|
||||
.sort((a, b) => Math.hypot(a.x - center.x, a.y - center.y) - Math.hypot(b.x - center.x, b.y - center.y))
|
||||
.slice(0, Math.max(2, Math.min(4, this.deploymentEligibleUnits().length + 1)));
|
||||
.slice(0, reserveLimit);
|
||||
}
|
||||
|
||||
private deploymentCameraCenterFromSlots(slots: DeploymentSlot[]) {
|
||||
@@ -8865,9 +8949,12 @@ export class BattleScene extends Phaser.Scene {
|
||||
}
|
||||
));
|
||||
statusText.setOrigin(0.5);
|
||||
this.trackSideObject(this.add.text(left + this.battleUiLength(12), top + this.battleUiLength(32), this.deploymentSubtitle(), {
|
||||
const preparationSummaryVisible =
|
||||
this.firstBattlePreparation.eligible && this.firstBattlePreparationCompletedCount() > 0;
|
||||
const deploymentSubtitleText = this.deploymentSubtitle();
|
||||
const subtitleText = this.trackSideObject(this.add.text(left + this.battleUiLength(12), top + this.battleUiLength(32), deploymentSubtitleText, {
|
||||
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
||||
fontSize: this.battleUiFontSize(10),
|
||||
fontSize: this.battleUiFontSize(preparationSummaryVisible ? 9 : 10),
|
||||
color: '#c8d2dd',
|
||||
wordWrap: { width: width - this.battleUiLength(24), useAdvancedWrap: true },
|
||||
maxLines: 2
|
||||
@@ -8963,6 +9050,10 @@ export class BattleScene extends Phaser.Scene {
|
||||
this.deploymentPanelLayout = {
|
||||
headerBounds: { x: left, y: top, width, height: headerHeight },
|
||||
statusBounds: { x: statusLeft, y: statusTop, width: statusWidth, height: statusHeight },
|
||||
subtitleText: deploymentSubtitleText,
|
||||
preparationSummaryBounds: preparationSummaryVisible
|
||||
? this.gameObjectBoundsDebug(subtitleText)
|
||||
: null,
|
||||
changeCount: deploymentChangeCount,
|
||||
recommended: recommendedDeployment,
|
||||
noticeBounds: { x: left, y: noticeTop, width, height: noticeHeight },
|
||||
@@ -9145,6 +9236,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
this.updateMiniMap();
|
||||
this.renderDeploymentMap();
|
||||
this.renderDeploymentPanel();
|
||||
this.refreshEnemyIntentForecast();
|
||||
soundDirector.playSelect();
|
||||
}
|
||||
|
||||
@@ -9161,6 +9253,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
this.updateMiniMap();
|
||||
this.renderDeploymentMap();
|
||||
this.renderDeploymentPanel();
|
||||
this.refreshEnemyIntentForecast();
|
||||
soundDirector.playSelect();
|
||||
}
|
||||
|
||||
@@ -9182,6 +9275,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
this.updateMiniMap();
|
||||
this.renderDeploymentMap();
|
||||
this.renderDeploymentPanel();
|
||||
this.refreshEnemyIntentForecast();
|
||||
soundDirector.playSelect();
|
||||
}
|
||||
|
||||
@@ -9247,7 +9341,9 @@ export class BattleScene extends Phaser.Scene {
|
||||
return;
|
||||
}
|
||||
|
||||
this.showPointerFeedback(tile, 0x9b4d45, '표시된 시작 구역 안에서만 배치할 수 있습니다.', key, 0x9b4d45, 0.08, 0.66);
|
||||
// Most of the map is intentionally outside the small deployment zone.
|
||||
// Keep ordinary hovering quiet; an attempted click already explains the restriction in the side panel.
|
||||
this.clearPointerFeedback();
|
||||
}
|
||||
|
||||
private showEnemyThreatRange(transitionTrigger: SidePanelTransitionTrigger = 'system') {
|
||||
@@ -9451,6 +9547,14 @@ export class BattleScene extends Phaser.Scene {
|
||||
}
|
||||
|
||||
private enemyIntentForecastEnabled() {
|
||||
if (
|
||||
this.phase === 'deployment' &&
|
||||
this.firstBattlePreparation.eligible &&
|
||||
this.firstBattlePreparation.scouting &&
|
||||
!this.battleOutcome
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return isEnemyIntentForecastAvailable({
|
||||
activeFaction: this.activeFaction,
|
||||
phase: this.phase,
|
||||
@@ -17027,7 +17131,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
private createItemStocks(campaign?: CampaignState) {
|
||||
const sortieItemAssignments = this.effectiveSortieItemAssignments(campaign);
|
||||
const usesSortieAssignments = Object.keys(sortieItemAssignments).length > 0;
|
||||
return new Map(
|
||||
const stocksByUnit = new Map(
|
||||
battleUnits.filter((unit) => unit.faction === 'ally').map((unit) => {
|
||||
const stocks = unit.faction === 'ally' && usesSortieAssignments
|
||||
? sortieItemAssignments[unit.id] ?? {}
|
||||
@@ -17038,6 +17142,13 @@ export class BattleScene extends Phaser.Scene {
|
||||
] as const;
|
||||
})
|
||||
);
|
||||
if (this.firstBattlePreparation.eligible && this.firstBattlePreparation.supplies) {
|
||||
const guanYuStocks = stocksByUnit.get('guan-yu');
|
||||
if (guanYuStocks) {
|
||||
guanYuStocks.set('salve', Math.min(2, (guanYuStocks.get('salve') ?? 0) + 1));
|
||||
}
|
||||
}
|
||||
return stocksByUnit;
|
||||
}
|
||||
|
||||
private createEnemyHomeTiles() {
|
||||
@@ -28484,6 +28595,10 @@ export class BattleScene extends Phaser.Scene {
|
||||
recommended: this.deploymentPanelLayout.recommended,
|
||||
headerBounds: { ...this.deploymentPanelLayout.headerBounds },
|
||||
statusBounds: { ...this.deploymentPanelLayout.statusBounds },
|
||||
subtitleText: this.deploymentPanelLayout.subtitleText,
|
||||
preparationSummaryBounds: this.deploymentPanelLayout.preparationSummaryBounds
|
||||
? { ...this.deploymentPanelLayout.preparationSummaryBounds }
|
||||
: null,
|
||||
noticeBounds: { ...this.deploymentPanelLayout.noticeBounds },
|
||||
noticeTextBounds: { ...this.deploymentPanelLayout.noticeTextBounds },
|
||||
roleBounds: this.deploymentPanelLayout.roleBounds.map((bounds) => ({ ...bounds })),
|
||||
@@ -28620,6 +28735,61 @@ export class BattleScene extends Phaser.Scene {
|
||||
};
|
||||
}
|
||||
|
||||
private firstBattlePreparationDebugState() {
|
||||
const preparation = this.firstBattlePreparation;
|
||||
const completedByCallback = {
|
||||
'enemy-intel': preparation.scouting,
|
||||
'deployment-flex': preparation.vanguard,
|
||||
'field-salve': preparation.supplies
|
||||
} as const;
|
||||
const reserveSlotCount = preparation.eligible
|
||||
? this.deploymentSlots().filter((slot) => !slot.unitId).length
|
||||
: 0;
|
||||
return {
|
||||
eligible: preparation.eligible,
|
||||
completedCount: this.firstBattlePreparationCompletedCount(),
|
||||
allComplete:
|
||||
preparation.eligible &&
|
||||
preparation.scouting &&
|
||||
preparation.vanguard &&
|
||||
preparation.supplies,
|
||||
summary: this.firstBattlePreparationSummary() ?? null,
|
||||
summaryVisible:
|
||||
this.phase === 'deployment' &&
|
||||
Boolean(this.deploymentPanelLayout?.preparationSummaryBounds),
|
||||
summaryBounds: this.deploymentPanelLayout?.preparationSummaryBounds
|
||||
? { ...this.deploymentPanelLayout.preparationSummaryBounds }
|
||||
: null,
|
||||
callbacks: prologueMilitiaCampPreparationPayoffs.map((payoff) => ({
|
||||
id: payoff.objectiveId,
|
||||
callback: payoff.battleCallback,
|
||||
label: payoff.battleLabel,
|
||||
completed: completedByCallback[payoff.battleCallback]
|
||||
})),
|
||||
enemyIntel: {
|
||||
enabled: preparation.scouting,
|
||||
visibleDuringDeployment:
|
||||
this.phase === 'deployment' &&
|
||||
preparation.scouting &&
|
||||
this.enemyIntentForecastEnabled(),
|
||||
forecastCount: this.enemyIntentForecasts.size,
|
||||
visualCount: this.enemyIntentVisuals.length
|
||||
},
|
||||
deploymentFlex: {
|
||||
enabled: preparation.vanguard,
|
||||
baseReserveSlotCount: preparation.eligible ? 2 : 0,
|
||||
bonusReserveSlotCount: preparation.vanguard ? 2 : 0,
|
||||
activeReserveSlotCount: reserveSlotCount
|
||||
},
|
||||
fieldSalve: {
|
||||
enabled: preparation.supplies,
|
||||
carrierUnitId: 'guan-yu',
|
||||
itemId: 'salve',
|
||||
count: this.itemStocks.get('guan-yu')?.get('salve') ?? 0
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private firstBattleTutorialDebugState() {
|
||||
const path = this.firstBattleTutorialPath;
|
||||
return {
|
||||
@@ -28743,6 +28913,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
? cloneCampaignSortieRecommendationSnapshot(this.launchSortieRecommendation)
|
||||
: null,
|
||||
battleHud: this.battleHudDebugState(),
|
||||
firstBattlePreparation: this.firstBattlePreparationDebugState(),
|
||||
firstBattleTutorial: this.firstBattleTutorialDebugState(),
|
||||
combatCutIn: combatCutInDebug,
|
||||
combatCutInStage: combatCutInDebug,
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
prologueMilitiaCampCommandChoices,
|
||||
prologueMilitiaCampNpcDefinitions,
|
||||
prologueMilitiaCampObjectiveDefinitions,
|
||||
prologueMilitiaCampPreparationPayoffs,
|
||||
prologueMilitiaCampRequiredObjectiveIds,
|
||||
type PrologueMilitiaCampCommandChoice,
|
||||
type PrologueMilitiaCampNpcDefinition,
|
||||
@@ -135,6 +136,11 @@ type CommandChoiceView = {
|
||||
selectLabel: Phaser.GameObjects.Text;
|
||||
};
|
||||
|
||||
type PreparationFeedbackView = {
|
||||
container: Phaser.GameObjects.Container;
|
||||
actorSprites: Phaser.GameObjects.Sprite[];
|
||||
};
|
||||
|
||||
const objectiveTutorialIds: Record<PrologueMilitiaCampRequiredObjectiveId, CampaignTutorialId> = {
|
||||
'review-scout-report': prologueMilitiaCampCampaignTutorialIds.reviewScoutReport,
|
||||
'ready-vanguard': prologueMilitiaCampCampaignTutorialIds.readyVanguard,
|
||||
@@ -159,6 +165,7 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
|
||||
private playerDirection: ExplorationCharacterDirection = 'north';
|
||||
private playerMoving = false;
|
||||
private npcViews = new Map<string, CampNpcView>();
|
||||
private preparationFeedbackViews = new Map<PrologueMilitiaCampRequiredObjectiveId, PreparationFeedbackView>();
|
||||
private npcMoveTweens = new Map<string, Phaser.Tweens.Tween>();
|
||||
private npcMovementPending = false;
|
||||
private blockers: Phaser.Geom.Rectangle[] = [];
|
||||
@@ -255,6 +262,7 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
|
||||
|
||||
ensureExplorationCharacterAnimations(this, characterTextureKeys);
|
||||
this.createActors();
|
||||
this.createPreparationFeedback();
|
||||
this.createDialoguePanel();
|
||||
this.loadingOverlay?.destroy();
|
||||
this.loadingOverlay = undefined;
|
||||
@@ -269,7 +277,7 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
|
||||
{
|
||||
speaker: '유비',
|
||||
textureKey: 'unit-liu-bei',
|
||||
text: '맹세만으로 백성을 지킬 수는 없다. 막사를 직접 돌며 관우의 정찰, 장비의 의병 대열, 군수관의 병기와 후송 준비를 확인하자.'
|
||||
text: '맹세만으로 백성을 지킬 수는 없다. 막사를 직접 돌며 모두가 살아 돌아올 준비를 갖추자.'
|
||||
}
|
||||
]);
|
||||
}
|
||||
@@ -380,6 +388,26 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
|
||||
unlocked: objective.id !== 'receive-command' || this.allRequiredObjectivesComplete()
|
||||
})),
|
||||
completedObjectiveIds: this.completedRequiredObjectiveIds(),
|
||||
preparationFeedback: prologueMilitiaCampPreparationPayoffs.map((payoff) => {
|
||||
const view = this.preparationFeedbackViews.get(payoff.objectiveId);
|
||||
return {
|
||||
id: payoff.objectiveId,
|
||||
completed: this.isObjectiveComplete(payoff.objectiveId),
|
||||
worldCue: payoff.worldCue,
|
||||
battleCallback: payoff.battleCallback,
|
||||
battleLabel: payoff.battleLabel,
|
||||
worldCueVisible: view?.container.visible ?? false,
|
||||
worldCueBounds: view ? this.boundsSnapshot(view.container.getBounds()) : null,
|
||||
ambientActorCount: view?.actorSprites.length ?? 0,
|
||||
ambientActors: view?.actorSprites.map((sprite) => ({
|
||||
x: sprite.x,
|
||||
y: sprite.y,
|
||||
textureKey: sprite.texture.key,
|
||||
animationKey: sprite.anims.currentAnim?.key ?? null,
|
||||
bounds: this.boundsSnapshot(sprite.getBounds())
|
||||
})) ?? []
|
||||
};
|
||||
}),
|
||||
exitUnlocked: this.allRequiredObjectivesComplete(),
|
||||
npcs: Array.from(this.npcViews.values()).map(({ definition, sprite }) => ({
|
||||
id: definition.id,
|
||||
@@ -499,6 +527,7 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
|
||||
prologueMilitiaCampRequiredObjectiveIds.forEach((objectiveId) => {
|
||||
completeCampaignTutorial(objectiveTutorialIds[objectiveId]);
|
||||
});
|
||||
this.syncPreparationFeedback();
|
||||
this.refreshObjectiveHud();
|
||||
this.refreshNpcMarkers();
|
||||
return this.completedRequiredObjectiveIds();
|
||||
@@ -532,6 +561,7 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
|
||||
this.playerDirection = 'north';
|
||||
this.playerMoving = false;
|
||||
this.npcViews.clear();
|
||||
this.preparationFeedbackViews.clear();
|
||||
this.npcMoveTweens.clear();
|
||||
this.npcMovementPending = false;
|
||||
this.blockers = [];
|
||||
@@ -835,6 +865,190 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
|
||||
}).setOrigin(0.5).setDepth(45);
|
||||
}
|
||||
|
||||
private createPreparationFeedback() {
|
||||
this.preparationFeedbackViews.set(
|
||||
'review-scout-report',
|
||||
this.createScoutPreparationFeedback()
|
||||
);
|
||||
this.preparationFeedbackViews.set(
|
||||
'ready-vanguard',
|
||||
this.createVanguardPreparationFeedback()
|
||||
);
|
||||
this.preparationFeedbackViews.set(
|
||||
'inspect-arms',
|
||||
this.createSupplyPreparationFeedback()
|
||||
);
|
||||
this.syncPreparationFeedback();
|
||||
}
|
||||
|
||||
private createScoutPreparationFeedback(): PreparationFeedbackView {
|
||||
const graphics = this.add.graphics();
|
||||
graphics.fillStyle(0xe9d6a1, 0.18);
|
||||
graphics.fillRoundedRect(302, 386, 106, 50, 4);
|
||||
graphics.lineStyle(3, 0xc49a55, 0.95);
|
||||
graphics.beginPath();
|
||||
graphics.moveTo(314, 425);
|
||||
graphics.lineTo(330, 413);
|
||||
graphics.lineTo(347, 418);
|
||||
graphics.lineTo(363, 399);
|
||||
graphics.lineTo(389, 392);
|
||||
graphics.strokePath();
|
||||
graphics.lineStyle(2, 0x87a478, 0.92);
|
||||
graphics.beginPath();
|
||||
graphics.moveTo(313, 429);
|
||||
graphics.lineTo(340, 432);
|
||||
graphics.lineTo(376, 423);
|
||||
graphics.lineTo(397, 432);
|
||||
graphics.strokePath();
|
||||
[
|
||||
[330, 413, 0xd8b15f],
|
||||
[363, 399, 0xbf5b4b],
|
||||
[389, 392, 0xbf5b4b]
|
||||
].forEach(([x, y, tone]) => {
|
||||
graphics.fillStyle(tone, 0.98);
|
||||
graphics.fillCircle(x, y, 5);
|
||||
graphics.lineStyle(1, 0x211812, 0.9);
|
||||
graphics.strokeCircle(x, y, 5);
|
||||
});
|
||||
|
||||
const stamp = this.add.text(400, 440, '정찰도', {
|
||||
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
||||
fontSize: '12px',
|
||||
color: '#f4ddb0',
|
||||
fontStyle: 'bold',
|
||||
backgroundColor: '#5b2b22d9',
|
||||
padding: { left: 6, right: 6, top: 2, bottom: 2 }
|
||||
}).setOrigin(1, 1);
|
||||
const container = this.add.container(0, 0, [graphics, stamp])
|
||||
.setDepth(410)
|
||||
.setVisible(false);
|
||||
return {
|
||||
container,
|
||||
actorSprites: []
|
||||
};
|
||||
}
|
||||
|
||||
private createVanguardPreparationFeedback(): PreparationFeedbackView {
|
||||
const graphics = this.add.graphics();
|
||||
const formationXs = [985, 1065, 1200, 1280];
|
||||
graphics.lineStyle(3, 0xc6a46a, 0.54);
|
||||
graphics.lineBetween(948, 543, 1318, 543);
|
||||
formationXs.forEach((x) => {
|
||||
graphics.lineStyle(2, 0xdec28b, 0.42);
|
||||
graphics.strokeEllipse(x, 527, 70, 30);
|
||||
graphics.fillStyle(0x251c12, 0.22);
|
||||
graphics.fillEllipse(x, 527, 62, 24);
|
||||
});
|
||||
|
||||
const actors: Phaser.GameObjects.Sprite[] = [];
|
||||
const objects: Phaser.GameObjects.GameObject[] = [graphics];
|
||||
formationXs.forEach((x, index) => {
|
||||
const shadow = this.add.ellipse(x, 523, 52, 18, 0x07100a, 0.42);
|
||||
const sprite = this.createCharacterSprite(
|
||||
explorationCharacterTextureKeyByUnitTextureKey['unit-shu-infantry'],
|
||||
x,
|
||||
505,
|
||||
78 - index,
|
||||
'north'
|
||||
);
|
||||
sprite.setData('preparationActor', true);
|
||||
actors.push(sprite);
|
||||
objects.push(shadow, sprite);
|
||||
});
|
||||
const container = this.add.container(0, 0, objects)
|
||||
.setDepth(610)
|
||||
.setVisible(false);
|
||||
return {
|
||||
container,
|
||||
actorSprites: actors
|
||||
};
|
||||
}
|
||||
|
||||
private createSupplyPreparationFeedback(): PreparationFeedbackView {
|
||||
const graphics = this.add.graphics();
|
||||
graphics.fillStyle(0x5e3925, 0.92);
|
||||
graphics.fillRoundedRect(400, 687, 94, 54, 8);
|
||||
graphics.lineStyle(4, 0xb9814e, 0.95);
|
||||
graphics.lineBetween(404, 713, 490, 713);
|
||||
graphics.lineBetween(447, 690, 447, 738);
|
||||
graphics.fillStyle(0x314d58, 0.95);
|
||||
graphics.fillCircle(410, 682, 17);
|
||||
graphics.fillRect(403, 678, 14, 22);
|
||||
graphics.fillStyle(0x8b6a48, 1);
|
||||
graphics.fillCircle(481, 680, 16);
|
||||
graphics.fillStyle(0xddd0b0, 0.92);
|
||||
graphics.fillRoundedRect(432, 669, 31, 24, 7);
|
||||
graphics.lineStyle(2, 0x9a3f36, 0.9);
|
||||
graphics.lineBetween(435, 681, 460, 681);
|
||||
graphics.lineBetween(447, 672, 447, 690);
|
||||
|
||||
const tags = [
|
||||
this.add.text(410, 682, '물', {
|
||||
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
||||
fontSize: '11px',
|
||||
color: '#eaf7fb',
|
||||
fontStyle: 'bold'
|
||||
}).setOrigin(0.5),
|
||||
this.add.text(447, 681, '약', {
|
||||
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
||||
fontSize: '11px',
|
||||
color: '#4a201c',
|
||||
fontStyle: 'bold'
|
||||
}).setOrigin(0.5),
|
||||
this.add.text(481, 680, '천', {
|
||||
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
||||
fontSize: '11px',
|
||||
color: '#f1e1bf',
|
||||
fontStyle: 'bold'
|
||||
}).setOrigin(0.5),
|
||||
this.add.text(447, 748, '후송 물자 적재', {
|
||||
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
||||
fontSize: '13px',
|
||||
color: '#f0dfbd',
|
||||
fontStyle: 'bold',
|
||||
backgroundColor: '#1b1712d4',
|
||||
padding: { left: 8, right: 8, top: 3, bottom: 3 }
|
||||
}).setOrigin(0.5)
|
||||
];
|
||||
const container = this.add.container(0, 0, [graphics, ...tags])
|
||||
.setDepth(850)
|
||||
.setVisible(false);
|
||||
return {
|
||||
container,
|
||||
actorSprites: []
|
||||
};
|
||||
}
|
||||
|
||||
private syncPreparationFeedback(
|
||||
animatedObjectiveId?: PrologueMilitiaCampRequiredObjectiveId
|
||||
) {
|
||||
prologueMilitiaCampRequiredObjectiveIds.forEach((objectiveId) => {
|
||||
const view = this.preparationFeedbackViews.get(objectiveId);
|
||||
if (!view) {
|
||||
return;
|
||||
}
|
||||
const completed = this.isObjectiveComplete(objectiveId);
|
||||
if (!completed) {
|
||||
this.tweens.killTweensOf(view.container);
|
||||
view.container.setVisible(false).setAlpha(0).setY(0);
|
||||
return;
|
||||
}
|
||||
if (animatedObjectiveId !== objectiveId || view.container.visible) {
|
||||
view.container.setVisible(true).setAlpha(1).setY(0);
|
||||
return;
|
||||
}
|
||||
this.tweens.killTweensOf(view.container);
|
||||
view.container.setVisible(true).setAlpha(0).setY(12);
|
||||
this.tweens.add({
|
||||
targets: view.container,
|
||||
alpha: 1,
|
||||
y: 0,
|
||||
duration: 520,
|
||||
ease: 'Back.easeOut'
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private addBlocker(x: number, y: number, width: number, height: number) {
|
||||
this.blockers.push(new Phaser.Geom.Rectangle(x, y, width, height));
|
||||
}
|
||||
@@ -1426,6 +1640,7 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
|
||||
return;
|
||||
}
|
||||
completeCampaignTutorial(objectiveTutorialIds[objectiveId]);
|
||||
this.syncPreparationFeedback(objectiveId);
|
||||
soundDirector.playObjectiveAchieved();
|
||||
this.refreshObjectiveHud();
|
||||
this.refreshNpcMarkers();
|
||||
|
||||
Reference in New Issue
Block a user