feat: make camp preparation affect first battle

This commit is contained in:
2026-07-27 04:19:40 +09:00
parent a726ba129f
commit 4d3f8b6a28
7 changed files with 608 additions and 50 deletions

View File

@@ -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: [

View File

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

View File

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