Improve sortie prep selection UI

This commit is contained in:
2026-07-03 22:39:13 +09:00
parent 487d70326a
commit 6fd5615bfd
2 changed files with 320 additions and 93 deletions

View File

@@ -220,6 +220,13 @@ type SortieRuleDefinition = {
note: string;
};
type SortieUnitAvailability = {
available: boolean;
label: string;
reason: string;
tone: 'ready' | 'selected' | 'recommended' | 'reserve' | 'disabled';
};
type SortieFormationSlot = {
role: SortieFormationRole;
label: string;
@@ -11255,10 +11262,11 @@ export class CampScene extends Phaser.Scene {
subtitle.setDepth(depth + 2);
this.renderSortieHeaderSummary(x + 690, y + 26, 464, 58, depth + 2);
this.renderSortieBriefing(x + 34, y + 104, 520, 168, depth + 2);
this.renderSortieChecklist(x + 576, y + 104, 578, 168, depth + 2);
this.renderSortieUnitSummary(x + 34, y + 292, 728, 286, depth + 2);
this.renderSortieFormationPlan(x + 782, y + 292, 372, 286, depth + 2);
this.renderSortieBriefing(x + 34, y + 104, 560, 168, depth + 2);
this.renderSortieChecklist(x + 612, y + 104, 542, 168, depth + 2);
this.renderSortieUnitSummary(x + 34, y + 292, 390, 286, depth + 2);
this.renderSortieFormationPlan(x + 442, y + 292, 350, 286, depth + 2);
this.renderSortieFocusPanel(x + 810, y + 292, 344, 286, depth + 2);
this.renderSortieRewardHint(x + 34, y + 596, 480, 52, depth + 2);
this.renderSortieReserveTraining(x + 528, y + 596, 314, 52, depth + 2);
@@ -11310,21 +11318,35 @@ export class CampScene extends Phaser.Scene {
bg.setStrokeStyle(1, palette.blue, 0.48);
const briefing = this.nextSortieBriefing();
this.trackSortie(this.add.text(x + 18, y + 14, briefing.eyebrow, this.textStyle(18, '#f2e3bf', true))).setDepth(depth + 1);
this.trackSortie(this.add.text(x + 18, y + 48, briefing.title, this.textStyle(23, '#d8b15f', true))).setDepth(depth + 1);
const scenario = this.nextSortieScenario();
const objective = scenario ? scenario.victoryConditionLabel : '군영 의정 진행';
const terrain = scenario ? this.sortieTerrainSummary(scenario) : '군영';
const enemies = scenario ? this.sortieEnemySummary(scenario) : '전투 없음';
const maxUnits = this.sortieMaxUnits(scenario);
this.trackSortie(this.add.text(x + 18, y + 12, briefing.eyebrow, this.textStyle(15, '#9fb0bf', true))).setDepth(depth + 1);
this.trackSortie(this.add.text(x + 18, y + 34, briefing.title, this.textStyle(22, '#f2e3bf', true))).setDepth(depth + 1);
this.trackSortie(
this.add.text(x + 18, y + 84, briefing.description, {
this.add.text(x + 18, y + 65, briefing.description, {
...this.textStyle(14, '#d4dce6'),
wordWrap: { width: width - 36, useAdvancedWrap: true },
lineSpacing: 4
})
).setDepth(depth + 1);
this.trackSortie(
this.add.text(x + 18, y + height - 30, `편성 주안점: ${this.nextSortieRule().note}`, {
...this.textStyle(12, '#d8b15f', true),
wordWrap: { width: width - 36, useAdvancedWrap: true }
lineSpacing: 3
})
).setDepth(depth + 1);
const chipY = y + height - 46;
const chipWidth = Math.floor((width - 48) / 4);
this.renderSortieInfoChip('목표', objective, x + 18, chipY, chipWidth, depth + 1);
this.renderSortieInfoChip('지형', terrain, x + 24 + chipWidth, chipY, chipWidth, depth + 1);
this.renderSortieInfoChip('적', enemies, x + 30 + chipWidth * 2, chipY, chipWidth, depth + 1);
this.renderSortieInfoChip('출전', `최대 ${maxUnits}`, x + 36 + chipWidth * 3, chipY, chipWidth, depth + 1);
}
private renderSortieInfoChip(label: string, value: string, x: number, y: number, width: number, depth: number) {
const bg = this.trackSortie(this.add.rectangle(x, y, width, 34, 0x101820, 0.94));
bg.setOrigin(0);
bg.setDepth(depth);
bg.setStrokeStyle(1, 0x53606c, 0.42);
this.trackSortie(this.add.text(x + 8, y + 4, label, this.textStyle(9, '#9fb0bf', true))).setDepth(depth + 1);
this.trackSortie(this.add.text(x + 8, y + 18, this.compactText(value, 13), this.textStyle(10, '#f2e3bf', true))).setDepth(depth + 1);
}
private renderSortieChecklist(x: number, y: number, width: number, height: number, depth: number) {
@@ -11334,13 +11356,16 @@ export class CampScene extends Phaser.Scene {
bg.setStrokeStyle(1, palette.gold, 0.5);
this.trackSortie(this.add.text(x + 18, y + 14, '준비 체크', this.textStyle(18, '#f2e3bf', true))).setDepth(depth + 1);
this.sortieChecklist().forEach((item, index) => {
const rowY = y + 42 + index * 27;
const items = this.sortieChecklist();
const rowGap = Math.max(15, Math.floor((height - 50) / Math.max(1, items.length)));
const compact = rowGap < 20;
items.forEach((item, index) => {
const rowY = y + 42 + index * rowGap;
const color = item.complete ? '#a8ffd0' : '#ffdf7b';
const status = item.complete ? '완료' : '주의';
this.trackSortie(this.add.text(x + 18, rowY, status, this.textStyle(13, color, true))).setDepth(depth + 1);
this.trackSortie(this.add.text(x + 70, rowY, item.label, this.textStyle(14, '#d4dce6', true))).setDepth(depth + 1);
const detail = this.trackSortie(this.add.text(x + 190, rowY, item.detail, this.textStyle(12, item.complete ? '#9fb0bf' : '#d8b15f')));
this.trackSortie(this.add.text(x + 18, rowY, status, this.textStyle(compact ? 10 : 13, color, true))).setDepth(depth + 1);
this.trackSortie(this.add.text(x + 68, rowY, item.label, this.textStyle(compact ? 11 : 14, '#d4dce6', true))).setDepth(depth + 1);
const detail = this.trackSortie(this.add.text(x + 184, rowY, this.compactText(item.detail, compact ? 36 : 42), this.textStyle(compact ? 10 : 12, item.complete ? '#9fb0bf' : '#d8b15f')));
detail.setDepth(depth + 1);
});
}
@@ -11350,12 +11375,13 @@ export class CampScene extends Phaser.Scene {
bg.setOrigin(0);
bg.setDepth(depth);
bg.setStrokeStyle(1, palette.blue, 0.48);
const allies = this.sortieAllies();
const displayAllies = this.sortieRosterDisplayUnits(allies);
const availableAllies = this.sortieAllies();
const rosterUnits = this.sortieRosterUnits();
const displayAllies = this.sortieRosterDisplayUnits(rosterUnits);
const selectedCount = this.selectedSortieUnitIds.length;
const maxUnits = this.sortieMaxUnits();
const reserveCount = Math.max(0, allies.length - selectedCount);
const rosterView = this.sortieRosterViewState(allies.length, height);
const reserveCount = Math.max(0, availableAllies.length - selectedCount);
const rosterView = this.sortieRosterViewState(rosterUnits.length, height);
const { denseRoster, rowGap, rowHeight, listTopOffset, listFooterHeight, maxVisibleRows, maxScroll } = rosterView;
this.sortieRosterScroll = Phaser.Math.Clamp(this.sortieRosterScroll, 0, maxScroll);
const visibleAllies = displayAllies.slice(this.sortieRosterScroll, this.sortieRosterScroll + maxVisibleRows);
@@ -11378,49 +11404,55 @@ export class CampScene extends Phaser.Scene {
bg.on('wheel', handleRosterWheel);
}
this.trackSortie(
this.add.text(x + 18, y + 14, `출전 ${selectedCount}/${maxUnits} · 대기 ${reserveCount}`, this.textStyle(18, '#f2e3bf', true))
this.add.text(x + 18, y + 14, `보유 무장 ${rosterUnits.length} · 출전 ${selectedCount}/${maxUnits}`, this.textStyle(17, '#f2e3bf', true))
).setDepth(depth + 1);
this.trackSortie(this.add.text(x + width - 18, y + 17, '선택 무장 우선 표시 · 클릭 전환', this.textStyle(11, '#9fb0bf'))).setOrigin(1, 0).setDepth(depth + 1);
this.trackSortie(this.add.text(x + width - 18, y + 17, `대기 ${reserveCount}`, this.textStyle(11, '#9fb0bf', true))).setOrigin(1, 0).setDepth(depth + 1);
visibleAllies.forEach((unit, index) => {
const rowY = y + listTopOffset + index * rowGap;
const selected = this.isSortieSelected(unit.id);
const required = this.isRequiredSortieUnit(unit.id);
const recruited = !foundingSortieUnitIds.has(unit.id);
const recommendation = this.sortieRecommendation(unit.id);
const summary = this.sortieUnitTacticalSummary(unit);
const availability = this.sortieUnitAvailability(unit);
const status = this.unitRosterStatus(unit);
const recommendedReserve = Boolean(recommendation && !selected);
const row = this.trackSortie(this.add.rectangle(x + 18, rowY - 5, width - 36, rowHeight, selected ? 0x172a22 : recommendedReserve ? 0x241f17 : 0x151b24, selected ? 0.96 : 0.84));
const disabled = !availability.available;
const rowColor = disabled ? 0x12161b : selected ? 0x172a22 : recommendedReserve ? 0x241f17 : 0x151b24;
const rowAlpha = disabled ? 0.62 : selected ? 0.96 : 0.84;
const row = this.trackSortie(this.add.rectangle(x + 18, rowY - 5, width - 36, rowHeight, rowColor, rowAlpha));
row.setOrigin(0);
row.setDepth(depth + 1);
row.setStrokeStyle(1, selected ? palette.green : recommendedReserve ? palette.gold : 0x53606c, selected ? 0.7 : recommendedReserve ? 0.54 : 0.36);
row.setStrokeStyle(1, disabled ? 0x53606c : selected ? palette.green : recommendedReserve ? palette.gold : 0x53606c, disabled ? 0.22 : selected ? 0.7 : recommendedReserve ? 0.54 : 0.36);
row.setInteractive({ useHandCursor: true });
row.on('wheel', handleRosterWheel);
row.on('pointerover', () => {
this.sortieFocusedUnitId = unit.id;
});
row.on('pointerdown', () => this.toggleSortieUnit(unit.id));
row.on('pointerdown', () => {
if (!availability.available) {
this.sortieFocusedUnitId = unit.id;
this.showCampNotice(`${unit.name}: ${availability.reason}`);
this.showSortiePrep();
return;
}
this.toggleSortieUnit(unit.id);
});
const markerLabel = required ? '필수 출전' : selected ? '출전' : recommendation ? '추천 대기' : recruited ? '합류 대기' : '대기';
this.trackSortie(this.add.text(x + 30, rowY, markerLabel, this.textStyle(denseRoster ? 9 : 11, required || recommendation ? '#ffdf7b' : selected ? '#a8ffd0' : '#9fb0bf', true))).setDepth(depth + 2);
this.renderSortieBattleUiIcon(this.unitClassIconKey(unit), x + 36, rowY + (denseRoster ? 3 : 10), denseRoster ? 18 : 24, depth + 2, disabled ? 0.42 : selected ? 1 : 0.68);
const nameColor = disabled ? '#68727e' : selected ? '#f2e3bf' : recommendedReserve ? '#d8b15f' : '#c8d2dd';
this.trackSortie(this.add.text(x + 58, rowY - 1, `${unit.name} Lv${unit.level}`, this.textStyle(denseRoster ? 11 : 13, nameColor, true))).setDepth(depth + 2);
const statusColor = status.tone === 'disabled' ? '#77818c' : status.tone === 'selected' ? '#a8ffd0' : status.tone === 'recommended' ? '#ffdf7b' : '#9fb0bf';
const statusText = this.trackSortie(this.add.text(x + width - 28, rowY - 1, status.label, this.textStyle(denseRoster ? 9 : 10, statusColor, true)));
statusText.setOrigin(1, 0);
statusText.setDepth(depth + 2);
this.trackSortie(this.add.text(x + 58, rowY + (denseRoster ? 12 : 17), this.compactText(`${unit.className} · HP ${unit.hp}/${unit.maxHp}`, 23), this.textStyle(denseRoster ? 9 : 10, disabled ? '#68727e' : '#d4dce6', true))).setDepth(depth + 2);
if (!denseRoster) {
this.renderSortieBattleUiIcon(this.unitClassIconKey(unit), x + 90, rowY + 8, 20, depth + 2, selected ? 1 : 0.58);
}
this.trackSortie(this.add.text(x + (denseRoster ? 84 : 112), rowY, `${unit.name} Lv ${unit.level}`, this.textStyle(denseRoster ? 12 : 14, selected ? '#f2e3bf' : recommendedReserve ? '#d8b15f' : '#87919c', true))).setDepth(depth + 2);
this.trackSortie(this.add.text(x + (denseRoster ? 200 : 234), rowY, this.compactText(`${unit.className} · ${getUnitClass(unit.classKey).role}`, denseRoster ? 17 : 17), this.textStyle(denseRoster ? 10 : 12, selected ? '#d4dce6' : '#77818c', true))).setDepth(depth + 2);
this.trackSortie(this.add.text(x + 424, rowY, denseRoster ? this.compactText(summary.statLine, 23) : `병력 ${unit.hp}/${unit.maxHp}`, this.textStyle(denseRoster ? 10 : 12, selected ? '#d4dce6' : '#77818c', true))).setDepth(depth + 2);
if (!denseRoster) {
this.drawSortieBar(x + 514, rowY + 6, 74, 7, unit.hp / unit.maxHp, selected ? palette.green : 0x53606c, depth + 2);
}
this.trackSortie(this.add.text(x + 610, rowY, this.compactText(denseRoster ? summary.bondLine : summary.terrainLine, denseRoster ? 15 : 13), this.textStyle(11, selected ? (denseRoster ? '#a8ffd0' : '#d8b15f') : recommendedReserve ? '#d8b15f' : '#77818c', true))).setDepth(depth + 2);
if (!denseRoster) {
this.renderSortieEquipmentIcons(unit, x + 112, rowY + 19, selected, depth + 2);
this.trackSortie(this.add.text(x + 190, rowY + 18, this.compactText(summary.equipmentLine, 29), this.textStyle(11, selected ? '#c8d2dd' : '#68727e'))).setDepth(depth + 2);
this.trackSortie(this.add.text(x + 424, rowY + 18, this.compactText(summary.statLine, 23), this.textStyle(11, selected ? '#c8d2dd' : '#68727e'))).setDepth(depth + 2);
const secondLine = recommendation && !selected ? `추천: ${recommendation.reason}` : summary.bondLine;
this.trackSortie(this.add.text(x + 610, rowY + 18, this.compactText(secondLine, 17), this.textStyle(11, selected ? '#a8ffd0' : recommendedReserve ? '#ffdf7b' : '#68727e', selected || recommendedReserve))).setDepth(depth + 2);
this.drawSortieBar(x + 218, rowY + 22, 58, 6, unit.hp / unit.maxHp, disabled ? 0x53606c : selected ? palette.green : palette.gold, depth + 2);
this.renderSortieEquipmentIcons(unit, x + 292, rowY + 24, !disabled && selected, depth + 2, 16, 12, 20);
}
const secondLine = disabled ? availability.reason : recommendation && !selected ? `추천: ${recommendation.reason}` : summary.bondLine;
this.trackSortie(this.add.text(x + 58, rowY + (denseRoster ? 23 : 32), this.compactText(secondLine, denseRoster ? 32 : 36), this.textStyle(denseRoster ? 8 : 9, disabled ? '#68727e' : selected ? '#a8ffd0' : recommendedReserve ? '#ffdf7b' : '#87919c', selected || recommendedReserve))).setDepth(depth + 2);
});
if (maxScroll > 0) {
@@ -11429,7 +11461,7 @@ export class CampScene extends Phaser.Scene {
this.add.text(
x + width - 18,
y + height - 20,
`${this.sortieRosterScroll + 1}-${rangeEnd}/${allies.length} · 휠로 이동`,
`${this.sortieRosterScroll + 1}-${rangeEnd}/${rosterUnits.length} · 휠로 이동`,
this.textStyle(11, '#9fb0bf', true)
)
);
@@ -11442,7 +11474,7 @@ export class CampScene extends Phaser.Scene {
const scrollTrack = this.trackSortie(this.add.rectangle(trackX, trackY, 4, trackHeight, 0x05070a, 0.78));
scrollTrack.setOrigin(0, 0);
scrollTrack.setDepth(depth + 1);
const thumbHeight = Math.max(24, trackHeight * (maxVisibleRows / allies.length));
const thumbHeight = Math.max(24, trackHeight * (maxVisibleRows / rosterUnits.length));
const thumbY = trackY + (trackHeight - thumbHeight) * (this.sortieRosterScroll / maxScroll);
const scrollThumb = this.trackSortie(this.add.rectangle(trackX, thumbY, 4, thumbHeight, palette.gold, 0.84));
scrollThumb.setOrigin(0, 0);
@@ -11459,6 +11491,11 @@ export class CampScene extends Phaser.Scene {
if (aSelected !== bSelected) {
return Number(bSelected) - Number(aSelected);
}
const aAvailable = this.sortieUnitAvailability(a).available;
const bAvailable = this.sortieUnitAvailability(b).available;
if (aAvailable !== bAvailable) {
return Number(bAvailable) - Number(aAvailable);
}
const aRecommended = Boolean(this.sortieRecommendation(a.id));
const bRecommended = Boolean(this.sortieRecommendation(b.id));
if (aRecommended !== bRecommended) {
@@ -11511,25 +11548,85 @@ export class CampScene extends Phaser.Scene {
bg.setStrokeStyle(1, palette.gold, 0.5);
const plan = this.sortiePlanSummary();
this.trackSortie(this.add.text(x + 18, y + 14, '전술 편성', this.textStyle(18, '#f2e3bf', true))).setDepth(depth + 1);
this.trackSortie(this.add.text(x + 18, y + 14, '출전 슬롯', this.textStyle(18, '#f2e3bf', true))).setDepth(depth + 1);
this.trackSortie(
this.add.text(x + width - 18, y + 17, `${plan.selectedCount}/${plan.maxCount}명 · 지형 ${plan.terrainGrade}`, this.textStyle(12, '#d8b15f', true))
).setOrigin(1, 0).setDepth(depth + 1);
this.renderSortieDeploymentMap(x + 18, y + 46, width - 36, 76, depth + 1);
this.trackSortie(this.add.text(x + 18, y + 128, '선택 무장 역할 지정', this.textStyle(11, '#d8b15f', true))).setDepth(depth + 1);
this.trackSortie(this.add.text(x + width - 18, y + 128, '슬롯 클릭으로 변경', this.textStyle(10, '#9fb0bf', true))).setOrigin(1, 0).setDepth(depth + 1);
this.renderSortieRoleSlots(x + 18, y + 143, width - 36, 48, plan, depth + 1);
this.renderSortieDeploymentMap(x + 16, y + 44, width - 32, 72, depth + 1);
this.renderSortieSlotCards(x + 16, y + 126, width - 32, 104, plan, depth + 1);
this.trackSortie(
this.add.text(
x + 18,
y + 196,
this.compactText(plan.warningLine, 42),
x + 16,
y + 236,
this.compactText(plan.warningLine, 43),
this.textStyle(11, plan.warnings.length > 0 ? '#ffdf7b' : '#a8ffd0', true)
)
).setDepth(depth + 1);
this.renderSortieFocusPanel(x + 18, y + 210, width - 36, 68, depth + 1);
this.renderSortieRoleQuickButtons(x + 16, y + 256, width - 32, 22, depth + 1);
}
private renderSortieSlotCards(x: number, y: number, width: number, height: number, plan: SortiePlanSummary, depth: number) {
const selectedUnits = this.selectedSortieUnits();
const maxCount = plan.maxCount;
const columns = 2;
const gap = 8;
const rows = Math.max(1, Math.ceil(maxCount / columns));
const slotWidth = Math.floor((width - gap) / columns);
const slotHeight = Math.min(30, Math.floor((height - gap * (rows - 1)) / rows));
Array.from({ length: maxCount }, (_, index) => {
const unit = selectedUnits[index];
const role = unit ? this.sortieFormationRole(unit) : undefined;
const slotX = x + (index % columns) * (slotWidth + gap);
const slotY = y + Math.floor(index / columns) * (slotHeight + gap);
const filled = Boolean(unit);
const focused = Boolean(unit && this.sortieFocusedUnitId === unit.id);
const bg = this.trackSortie(this.add.rectangle(slotX, slotY, slotWidth, slotHeight, filled ? 0x172a22 : 0x111922, filled ? 0.94 : 0.76));
bg.setOrigin(0);
bg.setDepth(depth);
bg.setStrokeStyle(focused ? 2 : 1, focused ? palette.gold : filled ? palette.green : 0x53606c, focused ? 0.92 : filled ? 0.58 : 0.34);
bg.setInteractive({ useHandCursor: true });
bg.on('pointerdown', () => {
if (!unit) {
this.showCampNotice('왼쪽 명단에서 출전할 무장을 선택하세요.');
return;
}
this.sortieFocusedUnitId = unit.id;
soundDirector.playSelect();
this.showSortiePrep();
});
const labelColor = filled ? '#f2e3bf' : '#7f8994';
this.trackSortie(this.add.text(slotX + 8, slotY + 5, `${index + 1}`, this.textStyle(10, labelColor, true))).setDepth(depth + 1);
if (unit && role) {
this.renderSortieBattleUiIcon(this.formationRoleIconKey(role), slotX + 28, slotY + slotHeight / 2, 18, depth + 1, 0.96);
const roleLabel = sortieFormationSlotDefinitions.find((slot) => slot.role === role)?.label ?? '예비';
this.trackSortie(this.add.text(slotX + 42, slotY + 4, this.compactText(unit.name, 8), this.textStyle(11, '#f2e3bf', true))).setDepth(depth + 1);
this.trackSortie(this.add.text(slotX + 42, slotY + 17, roleLabel, this.textStyle(8, '#9fb0bf', true))).setDepth(depth + 1);
} else {
this.trackSortie(this.add.text(slotX + 28, slotY + 8, '빈 슬롯', this.textStyle(10, '#7f8994', true))).setDepth(depth + 1);
}
});
}
private renderSortieRoleQuickButtons(x: number, y: number, width: number, height: number, depth: number) {
const focusedUnit = this.sortieFocusedUnit();
const enabled = Boolean(focusedUnit && this.isSortieSelected(focusedUnit.id));
const gap = 6;
const buttonWidth = Math.floor((width - gap * (sortieFormationSlotDefinitions.length - 1)) / sortieFormationSlotDefinitions.length);
sortieFormationSlotDefinitions.forEach((slot, index) => {
const buttonX = x + index * (buttonWidth + gap);
const active = Boolean(focusedUnit && this.sortieFormationRole(focusedUnit) === slot.role);
const bg = this.trackSortie(this.add.rectangle(buttonX, y, buttonWidth, height, active ? 0x263a2d : 0x111922, enabled ? 0.94 : 0.58));
bg.setOrigin(0);
bg.setDepth(depth);
bg.setStrokeStyle(1, active ? palette.green : 0x53606c, active ? 0.78 : 0.36);
bg.setInteractive({ useHandCursor: true });
bg.on('pointerdown', () => this.assignFocusedSortieRole(slot.role));
this.trackSortie(this.add.text(buttonX + buttonWidth / 2, y + 5, slot.label, this.textStyle(9, active ? '#a8ffd0' : enabled ? '#c8d2dd' : '#77818c', true))).setOrigin(0.5, 0).setDepth(depth + 1);
});
}
private renderSortieRoleSlots(x: number, y: number, width: number, height: number, plan: SortiePlanSummary, depth: number) {
@@ -11578,38 +11675,49 @@ export class CampScene extends Phaser.Scene {
}
const status = this.unitRosterStatus(unit);
const availability = this.sortieUnitAvailability(unit);
const recommendation = this.sortieRecommendation(unit.id);
const roleLabel = sortieFormationSlotDefinitions.find((slot) => slot.role === this.sortieFormationRole(unit))?.label ?? '예비';
const terrainScore = this.sortieTerrainScore(unit, this.nextSortieScenario());
const terrainGrade = this.sortieTerrainGrade(terrainScore);
const statusColor = status.tone === 'selected' ? '#a8ffd0' : status.tone === 'recommended' ? '#ffdf7b' : '#9fb0bf';
const headline = `${unit.name} Lv${unit.level} · ${unit.className} · ${roleLabel}`;
const equipLine = equipmentSlots
.map((slot) => {
const state = unit.equipment[slot];
const item = getItem(state.itemId);
return `${equipmentSlotLabels[slot]} ${item.name} Lv${state.level}`;
})
.join(' / ');
const statusColor = status.tone === 'disabled' ? '#77818c' : status.tone === 'selected' ? '#a8ffd0' : status.tone === 'recommended' ? '#ffdf7b' : '#9fb0bf';
const selected = this.isSortieSelected(unit.id);
const roleHint = selected ? `${roleLabel} 배치 · 역할 슬롯 클릭으로 변경` : '대기 중 · 명단 클릭으로 출전 전환';
const reason = recommendation?.reason ?? roleHint;
const reason = !availability.available
? availability.reason
: recommendation?.reason ?? (selected ? `${roleLabel} 배치 중입니다. 역할 버튼으로 시작 위치 성향을 바꿀 수 있습니다.` : '명단이나 버튼을 눌러 출전 슬롯에 배치할 수 있습니다.');
this.renderSortieBattleUiIcon(this.unitClassIconKey(unit), x + 24, y + 25, 32, depth + 1, 0.98);
this.trackSortie(this.add.text(x + 48, y + 6, this.compactText(headline, 24), this.textStyle(13, '#f2e3bf', true))).setDepth(depth + 1);
this.trackSortie(this.add.text(x + width - 12, y + 7, status.label, this.textStyle(11, statusColor, true))).setOrigin(1, 0).setDepth(depth + 1);
this.trackSortie(this.add.text(x + 18, y + 14, '선택 무장 상세', this.textStyle(17, '#f2e3bf', true))).setDepth(depth + 1);
this.trackSortie(this.add.text(x + width - 16, y + 16, status.label, this.textStyle(11, statusColor, true))).setOrigin(1, 0).setDepth(depth + 1);
this.renderSortieBattleUiIcon(this.unitClassIconKey(unit), x + 34, y + 58, 42, depth + 1, availability.available ? 1 : 0.5);
this.trackSortie(this.add.text(x + 66, y + 42, `${unit.name} Lv${unit.level}`, this.textStyle(18, availability.available ? '#f2e3bf' : '#87919c', true))).setDepth(depth + 1);
this.trackSortie(this.add.text(x + 66, y + 68, `${unit.className} · ${getUnitClass(unit.classKey).role}`, this.textStyle(12, availability.available ? '#d4dce6' : '#77818c', true))).setDepth(depth + 1);
this.trackSortie(this.add.text(x + width - 16, y + 68, `HP ${unit.hp}/${unit.maxHp}`, this.textStyle(12, unit.hp < unit.maxHp ? '#ffdf7b' : '#a8ffd0', true))).setOrigin(1, 0).setDepth(depth + 1);
this.drawSortieBar(x + 66, y + 88, width - 92, 7, unit.hp / unit.maxHp, availability.available ? palette.green : 0x53606c, depth + 1);
this.renderSortieStatChip('might', '무', unit.stats.might, x + 48, y + 27, 50, depth + 1);
this.renderSortieStatChip('intelligence', '지', unit.stats.intelligence, x + 104, y + 27, 50, depth + 1);
this.renderSortieStatChip('leadership', '', unit.stats.leadership, x + 160, y + 27, 50, depth + 1);
this.renderSortieStatChip('move', '', unit.move, x + 216, y + 27, 50, depth + 1);
this.trackSortie(this.add.text(x + 274, y + 30, `지형 ${terrainGrade} ${terrainScore}`, this.textStyle(10, '#d8b15f', true))).setDepth(depth + 1);
const statY = y + 108;
const chipWidth = 72;
this.renderSortieStatChip('might', '', unit.stats.might, x + 18, statY, chipWidth, depth + 1);
this.renderSortieStatChip('intelligence', '', unit.stats.intelligence, x + 96, statY, chipWidth, depth + 1);
this.renderSortieStatChip('leadership', '통', unit.stats.leadership, x + 174, statY, chipWidth, depth + 1);
this.renderSortieStatChip('move', '이', unit.move, x + 252, statY, chipWidth, depth + 1);
this.renderSortieEquipmentIcons(unit, x + 26, y + 52, true, depth + 1, 20, 16, 25);
this.trackSortie(this.add.text(x + 94, y + 46, this.compactText(equipLine, 38), this.textStyle(10, '#c8d2dd', true))).setDepth(depth + 1);
this.trackSortie(
this.add.text(x + 94, y + 58, this.compactText(reason, 32), this.textStyle(10, recommendation ? '#ffdf7b' : selected ? '#a8ffd0' : '#9fb0bf', true))
).setDepth(depth + 1);
this.trackSortie(this.add.text(x + 18, y + 137, `지형 ${terrainGrade} ${terrainScore} · ${this.sortieTerrainLine(unit)}`, this.textStyle(11, '#d8b15f', true))).setDepth(depth + 1);
this.trackSortie(this.add.text(x + 18, y + 157, this.compactText(reason, 46), this.textStyle(11, recommendation ? '#ffdf7b' : selected ? '#a8ffd0' : availability.available ? '#c8d2dd' : '#87919c', true))).setDepth(depth + 1);
equipmentSlots.forEach((slot, index) => {
const rowY = y + 178 + index * 22;
const state = unit.equipment[slot];
const item = getItem(state.itemId);
const iconX = x + 28;
this.renderSortieBattleUiIcon(this.itemIconKeyForSortie(slot), iconX, rowY + 8, 18, depth + 1, availability.available ? 0.96 : 0.5);
this.trackSortie(this.add.text(x + 46, rowY + 2, `${equipmentSlotLabels[slot]} · ${item.name} Lv${state.level}`, this.textStyle(11, '#f2e3bf', true))).setDepth(depth + 1);
this.trackSortie(this.add.text(x + 184, rowY + 2, this.compactText(this.itemBonusText(item), 22), this.textStyle(10, '#9fb0bf', true))).setDepth(depth + 1);
this.drawSortieBar(x + 46, rowY + 16, 118, 5, state.exp / equipmentExpToNext(state.level), slot === 'weapon' ? palette.gold : slot === 'armor' ? palette.blue : palette.green, depth + 1);
});
const actionLabel = selected ? (this.isRequiredSortieUnit(unit.id) ? '필수 출전' : '출전 해제') : '출전 등록';
this.renderSortiePanelButton(actionLabel, x + 18, y + height - 34, 108, 24, availability.available, selected, () => this.toggleSortieUnit(unit.id), depth + 1);
this.trackSortie(this.add.text(x + 140, y + height - 29, `${roleLabel} · 역할은 중앙 버튼으로 변경`, this.textStyle(10, selected ? '#a8ffd0' : '#9fb0bf', true))).setDepth(depth + 1);
}
private renderSortieBattleUiIcon(iconKey: BattleUiIconKey, x: number, y: number, size: number, depth: number, alpha = 1) {
@@ -11686,6 +11794,46 @@ export class CampScene extends Phaser.Scene {
});
}
private renderSortiePanelButton(
label: string,
x: number,
y: number,
width: number,
height: number,
enabled: boolean,
active: boolean,
action: () => void,
depth: number
) {
const bg = this.trackSortie(this.add.rectangle(x, y, width, height, active ? 0x263a2d : 0x1a2630, enabled ? 0.96 : 0.58));
bg.setOrigin(0);
bg.setDepth(depth);
bg.setStrokeStyle(1, active ? palette.green : enabled ? palette.gold : 0x53606c, active ? 0.72 : enabled ? 0.62 : 0.32);
bg.setInteractive({ useHandCursor: true });
bg.on('pointerover', () => {
if (enabled) {
bg.setFillStyle(active ? 0x314738 : 0x283947, 0.98);
}
});
bg.on('pointerout', () => bg.setFillStyle(active ? 0x263a2d : 0x1a2630, enabled ? 0.96 : 0.58));
bg.on('pointerdown', action);
const text = this.trackSortie(this.add.text(x + width / 2, y + 5, label, this.textStyle(10, enabled ? '#f2e3bf' : '#87919c', true)));
text.setOrigin(0.5, 0);
text.setDepth(depth + 1);
text.setInteractive({ useHandCursor: true });
text.on('pointerdown', action);
}
private itemIconKeyForSortie(slot: EquipmentSlot): BattleUiIconKey {
if (slot === 'armor') {
return 'armor';
}
if (slot === 'accessory') {
return 'accessory';
}
return 'sword';
}
private sortieUnitTacticalSummary(unit: UnitData): SortieUnitTacticalSummary {
return {
statLine: `${unit.stats.might}${unit.stats.intelligence}${unit.stats.leadership}${unit.stats.agility}${unit.stats.luck}`,
@@ -11778,6 +11926,27 @@ export class CampScene extends Phaser.Scene {
return counts;
}
private sortieTerrainSummary(scenario: BattleScenarioDefinition) {
const entries = Object.entries(this.passableTerrainCounts(scenario)) as [TerrainType, number][];
const topTerrains = entries
.sort((a, b) => b[1] - a[1])
.slice(0, 3)
.map(([terrain]) => terrainRules[terrain].label);
return topTerrains.join('/') || '미정';
}
private sortieEnemySummary(scenario: BattleScenarioDefinition) {
const counts = new Map<string, number>();
scenario.units
.filter((unit) => unit.faction === 'enemy')
.forEach((unit) => counts.set(unit.className, (counts.get(unit.className) ?? 0) + 1));
return [...counts.entries()]
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0], 'ko'))
.slice(0, 3)
.map(([label, count]) => `${label} ${count}`)
.join(', ') || '미정';
}
private nextSortieScenario() {
const nextBattleId = this.currentSortieFlow().nextBattleId;
return nextBattleId ? getBattleScenario(nextBattleId) : undefined;
@@ -11995,19 +12164,19 @@ export class CampScene extends Phaser.Scene {
const maxCount = this.sortieMaxUnits(scenario);
if (selectedUnits.length < maxCount) {
warnings.push(`아직 ${maxCount - selectedUnits.length}명 더 출전할 수 있습니다.`);
warnings.push(`빈 슬롯 ${maxCount - selectedUnits.length}개가 남아 있습니다.`);
}
if (missingRecommended.length > 0) {
warnings.push(`추천 무장 ${missingRecommended.map((entry) => this.unitName(entry.unitId)).join(', ')} 대기 중입니다.`);
}
if (roleCounts.front <= 0) {
warnings.push('전열 병과가 없어 길목을 버티기 어렵습니다.');
warnings.push('전열 담당이 없어 길목을 버티기 어렵습니다.');
}
if (roleCounts.flank <= 0) {
warnings.push('돌파 병과가 없어 추격과 측면 압박이 느릴 수 있습니다.');
warnings.push('돌파 담당이 없어 추격과 측면 압박이 느릴 수 있습니다.');
}
if (roleCounts.support <= 0) {
warnings.push('후원 병과가 없어 회복, 보급, 원거리 견제가 약합니다.');
warnings.push('후원 담당이 없어 회복, 보급, 원거리 견제가 약합니다.');
}
if (activeBondCount <= 0 && selectedUnits.length > 1) {
warnings.push('함께 출전하는 공명 조합이 없습니다.');
@@ -12444,6 +12613,10 @@ export class CampScene extends Phaser.Scene {
}
private unitRosterStatus(unit: UnitData) {
const availability = this.sortieUnitAvailability(unit);
if (!availability.available) {
return { label: availability.label, tone: 'disabled' as const };
}
const selected = this.isSortieSelected(unit.id);
const recommendation = this.sortieRecommendation(unit.id);
if (this.isRequiredSortieUnit(unit.id)) {
@@ -13414,13 +13587,44 @@ export class CampScene extends Phaser.Scene {
private sortieAllies() {
const scenario = this.nextSortieScenario();
const rule = this.nextSortieRule(scenario);
const scenarioAllyIds = scenario
? new Set(scenario.units.filter((unit) => unit.faction === 'ally').map((unit) => unit.id))
: undefined;
return this.currentUnits().filter((unit) => this.sortieUnitAvailability(unit, scenario, rule).available);
}
private sortieRosterUnits() {
const campaignOrder = new Map(this.currentUnits().map((unit, index) => [unit.id, index]));
return this.currentUnits()
.filter((unit) => unit.faction === 'ally')
.sort((a, b) => (campaignOrder.get(a.id) ?? 0) - (campaignOrder.get(b.id) ?? 0));
}
private sortieUnitAvailability(
unit: UnitData,
scenario = this.nextSortieScenario(),
rule = this.nextSortieRule(scenario)
): SortieUnitAvailability {
if (unit.faction !== 'ally') {
return { available: false, label: '적', reason: '아군 명단에 포함되지 않습니다.', tone: 'disabled' };
}
if (unit.hp <= 0) {
return { available: false, label: '부상', reason: '병력이 0이라 출전할 수 없습니다. 보급 화면에서 회복이 필요합니다.', tone: 'disabled' };
}
const excludedUnitIds = new Set(rule.excludedUnitIds ?? []);
return this.currentUnits().filter(
(unit) => unit.faction === 'ally' && unit.hp > 0 && !excludedUnitIds.has(unit.id) && (!scenarioAllyIds || scenarioAllyIds.has(unit.id))
);
if (excludedUnitIds.has(unit.id)) {
return { available: false, label: '제외', reason: '이번 장면의 지휘 조건상 출전 후보에서 제외됩니다.', tone: 'disabled' };
}
const scenarioAllyIds = scenario
? new Set(scenario.units.filter((candidate) => candidate.faction === 'ally').map((candidate) => candidate.id))
: undefined;
if (scenarioAllyIds && !scenarioAllyIds.has(unit.id)) {
return { available: false, label: '후방', reason: '이번 전투의 시작 배치 후보가 아닙니다.', tone: 'disabled' };
}
if (this.isSortieSelected(unit.id)) {
return { available: true, label: '출전', reason: '출전 슬롯에 배치되었습니다.', tone: 'selected' };
}
if (this.sortieRecommendation(unit.id, scenario)) {
return { available: true, label: '추천', reason: this.sortieRecommendation(unit.id, scenario)?.reason ?? '이번 전투 추천 무장입니다.', tone: 'recommended' };
}
return { available: true, label: '대기', reason: '출전 가능한 후보입니다.', tone: 'reserve' };
}
private selectedSortieUnits() {
@@ -13463,11 +13667,16 @@ export class CampScene extends Phaser.Scene {
}
private toggleSortieUnit(unitId: string) {
const unit = this.sortieAllies().find((candidate) => candidate.id === unitId);
const unit = this.sortieRosterUnits().find((candidate) => candidate.id === unitId);
if (!unit) {
return;
}
this.sortieFocusedUnitId = unitId;
const availability = this.sortieUnitAvailability(unit);
if (!availability.available) {
this.showCampNotice(`${unit.name}: ${availability.reason}`);
return;
}
if (this.isRequiredSortieUnit(unitId)) {
this.showCampNotice(`${unit.name}는 반드시 출전해야 합니다.`);
return;

View File

@@ -19,6 +19,16 @@ export class TitleScene extends Phaser.Scene {
this.settingsPanel = undefined;
this.navigating = false;
if (this.shouldOpenDebugSortiePrep()) {
this.time.delayedCall(0, () => {
void this.navigateTo('CampScene', {
openSortiePrep: true,
skipIntroStory: true
});
});
return;
}
const { width, height } = this.scale;
this.drawBackground(width, height);
this.drawAtmosphere(width, height);
@@ -37,6 +47,14 @@ export class TitleScene extends Phaser.Scene {
this.input.keyboard?.once('keydown-ENTER', () => this.startGame());
}
private shouldOpenDebugSortiePrep() {
if (typeof window === 'undefined') {
return false;
}
const params = new URLSearchParams(window.location.search);
return params.has('debugSortiePrep') && (params.has('debug') || import.meta.env.DEV);
}
private drawBackground(width: number, height: number) {
const background = this.add.image(width / 2, height / 2, 'title-taoyuan');
const texture = this.textures.get('title-taoyuan').getSourceImage();