From 6fd5615bfd08390686c873780b8a8d7f98d46ed1 Mon Sep 17 00:00:00 2001 From: Wickedness Date: Fri, 3 Jul 2026 22:39:13 +0900 Subject: [PATCH] Improve sortie prep selection UI --- src/game/scenes/CampScene.ts | 395 ++++++++++++++++++++++++++-------- src/game/scenes/TitleScene.ts | 18 ++ 2 files changed, 320 insertions(+), 93 deletions(-) diff --git a/src/game/scenes/CampScene.ts b/src/game/scenes/CampScene.ts index 613176d..77b56f6 100644 --- a/src/game/scenes/CampScene.ts +++ b/src/game/scenes/CampScene.ts @@ -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(); + 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; diff --git a/src/game/scenes/TitleScene.ts b/src/game/scenes/TitleScene.ts index 4036064..4c71f45 100644 --- a/src/game/scenes/TitleScene.ts +++ b/src/game/scenes/TitleScene.ts @@ -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();