From 6b863882aa9c762d99ae601fd78269d904d8878b Mon Sep 17 00:00:00 2001 From: Wickedness Date: Tue, 23 Jun 2026 01:07:17 +0900 Subject: [PATCH] Add Xu Province recruit sortie flow --- docs/roadmap.md | 3 +- scripts/verify-flow.mjs | 14 +++- src/game/data/battleRules.ts | 60 +++++++++++++- src/game/data/scenario.ts | 64 +++++++++++++++ src/game/scenes/BattleScene.ts | 27 +++++-- src/game/scenes/CampScene.ts | 133 +++++++++++++++++++++++++++----- src/game/state/campaignState.ts | 62 +++++++++++++++ 7 files changed, 330 insertions(+), 33 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index 243f491..7f33087 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -26,12 +26,13 @@ Build a small complete tactical RPG loop that can grow into a longer Romance of - Sixth battle Jieqiao relief route under Gongsun Zan, with a story bridge toward Tao Qian and Xu Province - Seventh battle Xu Province rescue route against Cao Cao's vanguard, with Tao Qian entrusting Xu Province as the next endpoint - Sortie preparation selection in camp so Liu Bei is required and optional allied officers can be deployed or benched before battle +- Xu Province camp recruitment for Jian Yong and Mi Zhu, including new strategist/quartermaster roles, bond dialogue, and selectable sortie participation - Flow verification script from title through the seventh battle victory, sortie selection, and camp save state ## Next Steps 1. Add the Tao Qian handoff story and first Xu Province governance camp -2. Introduce recruitable allied officers and battle sortie limits so the selection screen becomes a stronger strategic choice +2. Use the expanded Xu Province roster in the next Lu Bu pressure arc so sortie selection has direct battle consequences 3. Turn story-only endpoint bridges into a more explicit chapter selection/progress view 4. Apply treasure equipment effects to damage, defense, recovery, and command rules 5. Keep expanding scenarios through the long campaign instead of treating the current slice as an ending diff --git a/scripts/verify-flow.mjs b/scripts/verify-flow.mjs index fa3687d..260239c 100644 --- a/scripts/verify-flow.mjs +++ b/scripts/verify-flow.mjs @@ -841,11 +841,13 @@ try { if ( seventhCampState?.campBattleId !== 'seventh-battle-xuzhou-rescue' || seventhCampState.campTitle !== '서주 인수 준비 군영' || - seventhCampState.availableDialogueIds?.length !== 3 || + seventhCampState.availableDialogueIds?.length !== 5 || !seventhCampState.availableDialogueIds.every((id) => id.endsWith('xuzhou')) || - !seventhCampState.campaign?.roster?.some((unit) => unit.id === 'guan-yu') + !seventhCampState.campaign?.roster?.some((unit) => unit.id === 'guan-yu') || + !seventhCampState.campaign?.roster?.some((unit) => unit.id === 'jian-yong') || + !seventhCampState.campaign?.roster?.some((unit) => unit.id === 'mi-zhu') ) { - throw new Error(`Expected seventh camp to expose Xu Province dialogue set and preserved roster: ${JSON.stringify(seventhCampState)}`); + throw new Error(`Expected seventh camp to expose Xu Province dialogue set, preserved roster, and new recruits: ${JSON.stringify(seventhCampState)}`); } await page.mouse.click(1120, 38); @@ -854,6 +856,12 @@ try { if (!seventhCampSortieState?.sortieVisible) { throw new Error(`Expected seventh camp story bridge overlay: ${JSON.stringify(seventhCampSortieState)}`); } + await page.mouse.click(256, 492); + await page.waitForTimeout(120); + const seventhCampRecruitSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + if (!seventhCampRecruitSortieState.selectedSortieUnitIds?.includes('jian-yong')) { + throw new Error(`Expected recruit to be selectable in seventh camp sortie prep: ${JSON.stringify(seventhCampRecruitSortieState)}`); + } await page.mouse.click(938, 596); await page.waitForFunction(() => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; diff --git a/src/game/data/battleRules.ts b/src/game/data/battleRules.ts index 648d107..f6074f5 100644 --- a/src/game/data/battleRules.ts +++ b/src/game/data/battleRules.ts @@ -1,6 +1,16 @@ export type TerrainType = 'plain' | 'road' | 'forest' | 'hill' | 'village' | 'fort' | 'camp' | 'river' | 'cliff'; -export type UnitClassKey = 'lord' | 'infantry' | 'cavalry' | 'spearman' | 'archer' | 'bandit' | 'yellowTurban' | 'rebelLeader'; +export type UnitClassKey = + | 'lord' + | 'infantry' + | 'cavalry' + | 'spearman' + | 'archer' + | 'strategist' + | 'quartermaster' + | 'bandit' + | 'yellowTurban' + | 'rebelLeader'; export type TerrainRule = { label: string; @@ -140,6 +150,54 @@ export const unitClasses: Record = { hill: 1 } }, + strategist: { + key: 'strategist', + name: '책사', + family: '책사계', + role: '책략 / 보조', + description: '직접 전투력은 낮지만 책략과 지휘 보조로 전장의 흐름을 바꾸는 병과.', + terrainRatings: { + plain: 95, + road: 100, + forest: 95, + hill: 105, + village: 110, + fort: 115, + camp: 115, + river: 0, + cliff: 0 + }, + movementCosts: { + hill: 1, + village: 1, + fort: 1, + camp: 1 + } + }, + quartermaster: { + key: 'quartermaster', + name: '군량관', + family: '보급계', + role: '회복 / 보급', + description: '군량과 보급로를 관리해 장기전에 강하고 마을과 진영에서 안정적인 병과.', + terrainRatings: { + plain: 95, + road: 105, + forest: 90, + hill: 90, + village: 115, + fort: 105, + camp: 120, + river: 0, + cliff: 0 + }, + movementCosts: { + road: 1, + village: 1, + fort: 1, + camp: 1 + } + }, bandit: { key: 'bandit', name: '도적', diff --git a/src/game/data/scenario.ts b/src/game/data/scenario.ts index e1e723b..9fbeed8 100644 --- a/src/game/data/scenario.ts +++ b/src/game/data/scenario.ts @@ -2566,6 +2566,51 @@ export const seventhBattleUnits: UnitData[] = [ } ]; +export const xuzhouRecruitUnits: UnitData[] = [ + { + id: 'jian-yong', + name: '간옹', + faction: 'ally', + className: '책사', + classKey: 'strategist', + level: 4, + exp: 12, + hp: 26, + maxHp: 26, + attack: 7, + move: 4, + stats: { might: 42, intelligence: 78, leadership: 66, agility: 64, luck: 72 }, + equipment: { + weapon: { itemId: 'training-sword', level: 1, exp: 8 }, + armor: { itemId: 'cloth-armor', level: 1, exp: 6 }, + accessory: { itemId: 'war-manual', level: 1, exp: 0 } + }, + x: 3, + y: 17 + }, + { + id: 'mi-zhu', + name: '미축', + faction: 'ally', + className: '군량관', + classKey: 'quartermaster', + level: 4, + exp: 8, + hp: 28, + maxHp: 28, + attack: 7, + move: 4, + stats: { might: 40, intelligence: 74, leadership: 70, agility: 58, luck: 82 }, + equipment: { + weapon: { itemId: 'training-sword', level: 1, exp: 6 }, + armor: { itemId: 'cloth-armor', level: 1, exp: 6 }, + accessory: { itemId: 'grain-pouch', level: 1, exp: 0 } + }, + x: 4, + y: 17 + } +]; + export const firstBattleBonds: BattleBond[] = [ { id: 'liu-bei__guan-yu', @@ -2593,6 +2638,25 @@ export const firstBattleBonds: BattleBond[] = [ } ]; +export const xuzhouRecruitBonds: BattleBond[] = [ + { + id: 'liu-bei__jian-yong', + unitIds: ['liu-bei', 'jian-yong'], + title: '오랜 벗', + level: 48, + exp: 0, + description: '간옹은 유비가 흔들릴 때 현실적인 말로 길을 정리해 주는 오랜 벗이다.' + }, + { + id: 'liu-bei__mi-zhu', + unitIds: ['liu-bei', 'mi-zhu'], + title: '서주 후원', + level: 42, + exp: 0, + description: '미축은 서주의 민심과 군량을 묶어 유비군이 오래 버틸 수 있게 돕는다.' + } +]; + export const secondBattleBonds: BattleBond[] = firstBattleBonds.map(cloneBattleBondForScenario); export const thirdBattleBonds: BattleBond[] = firstBattleBonds.map(cloneBattleBondForScenario); export const fourthBattleBonds: BattleBond[] = firstBattleBonds.map(cloneBattleBondForScenario); diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index 6e894be..6047deb 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -68,6 +68,8 @@ const unitTextureByClass: Partial> = { infantry: 'unit-guan-yu', spearman: 'unit-zhang-fei', archer: 'unit-rebel-archer', + strategist: 'unit-liu-bei', + quartermaster: 'unit-liu-bei', cavalry: 'unit-rebel-cavalry', bandit: 'unit-rebel', yellowTurban: 'unit-rebel', @@ -470,17 +472,22 @@ const usableCatalog: Record = { const unitStrategyIds: Record = { 'liu-bei': ['aid', 'encourage'], 'guan-yu': ['fireTactic'], - 'zhang-fei': ['roar'] + 'zhang-fei': ['roar'], + 'jian-yong': ['aid', 'encourage', 'fireTactic'], + 'mi-zhu': ['aid', 'encourage'] }; const initialItemStocks: Record> = { 'liu-bei': { bean: 2, salve: 1 }, 'guan-yu': { bean: 1 }, - 'zhang-fei': { bean: 1, wine: 1 } + 'zhang-fei': { bean: 1, wine: 1 }, + 'jian-yong': { bean: 1 }, + 'mi-zhu': { bean: 2, salve: 1 } }; const attackRangeByClass: Partial> = { - archer: 2 + archer: 2, + strategist: 2 }; const enemyAiByUnitId: Record = { @@ -846,7 +853,7 @@ export class BattleScene extends Phaser.Scene { private drawUnits() { battleUnits.forEach((unit) => { const sizeRatio = unit.faction === 'ally' ? 1.16 : 1.1; - const textureBase = unitTexture[unit.id] ?? unitTextureByClass[unit.classKey] ?? 'unit-rebel'; + const textureBase = this.unitTextureKey(unit); const sprite = this.add.sprite(this.tileCenterX(unit.x), this.tileCenterY(unit.y), textureBase, this.unitFrameIndex('south')); sprite.setName(`unit-${unit.id}`); sprite.setDisplaySize(this.layout.tileSize * sizeRatio, this.layout.tileSize * sizeRatio); @@ -4257,7 +4264,7 @@ export class BattleScene extends Phaser.Scene { attackerSprite.setDisplaySize(126, 126); const defenderSprite = this.trackCombatObject( - this.add.sprite(defenderX, groundY, unitTexture[result.defender.id] ?? 'unit-rebel', this.unitFrameIndex(defenderDirection)) + this.add.sprite(defenderX, groundY, this.unitTextureKey(result.defender), this.unitFrameIndex(defenderDirection)) ); defenderSprite.setDepth(depth + 6); defenderSprite.setDisplaySize(126, 126); @@ -4361,7 +4368,7 @@ export class BattleScene extends Phaser.Scene { userSprite.setDepth(depth + 3); userSprite.setDisplaySize(112, 112); - const targetSprite = this.trackCombatObject(this.add.sprite(left + panelWidth - 160, top + 146, unitTexture[result.target.id] ?? 'unit-rebel', this.unitFrameIndex('west'))); + const targetSprite = this.trackCombatObject(this.add.sprite(left + panelWidth - 160, top + 146, this.unitTextureKey(result.target), this.unitFrameIndex('west'))); targetSprite.setDepth(depth + 3); targetSprite.setDisplaySize(112, 112); @@ -4423,7 +4430,7 @@ export class BattleScene extends Phaser.Scene { portrait.setDepth(85); portrait.setDisplaySize(58, 80); } else { - const unitIcon = this.trackCombatObject(this.add.sprite(x + 38, y + 58, unitTexture[unit.id] ?? 'unit-rebel', this.unitFrameIndex('south'))); + const unitIcon = this.trackCombatObject(this.add.sprite(x + 38, y + 58, this.unitTextureKey(unit), this.unitFrameIndex('south'))); unitIcon.setDepth(85); unitIcon.setDisplaySize(64, 64); } @@ -5586,8 +5593,12 @@ export class BattleScene extends Phaser.Scene { return unitFrameRows[direction] * 4 + frame; } + private unitTextureKey(unit: UnitData) { + return unitTexture[unit.id] ?? unitTextureByClass[unit.classKey] ?? 'unit-rebel'; + } + private unitActionTexture(unit: UnitData) { - const base = unitTexture[unit.id] ?? 'unit-rebel'; + const base = this.unitTextureKey(unit); const actionKey = `${base}-actions`; return this.textures.exists(actionKey) ? actionKey : base; } diff --git a/src/game/scenes/CampScene.ts b/src/game/scenes/CampScene.ts index 3ecefc4..48de9fa 100644 --- a/src/game/scenes/CampScene.ts +++ b/src/game/scenes/CampScene.ts @@ -4,9 +4,10 @@ import { equipmentExpToNext, equipmentSlotLabels, equipmentSlots, getItem, type import { getUnitClass } from '../data/battleRules'; import { defaultBattleScenario } from '../data/battles'; import { getSortieFlow } from '../data/campaignFlow'; -import { firstBattleBonds, firstBattleUnits, type PortraitKey, type UnitData } from '../data/scenario'; +import { firstBattleBonds, firstBattleUnits, xuzhouRecruitBonds, xuzhouRecruitUnits, type PortraitKey, type UnitData } from '../data/scenario'; import { applyCampBondExp, + ensureCampaignRosterUnits, getCampaignState, getFirstBattleReport, markCampaignStep, @@ -702,6 +703,60 @@ const campDialogues: CampDialogue[] = [ rewardExp: 7 } ] + }, + { + id: 'liu-jian-yong-after-xuzhou', + title: '오랜 벗의 조언', + availableAfterBattleIds: [campBattleIds.seventh], + unitIds: ['liu-bei', 'jian-yong'], + bondId: 'liu-bei__jian-yong', + rewardExp: 18, + lines: [ + '간옹: 현덕, 서주를 맡는 일은 칼을 드는 일보다 더 무겁네. 백성은 말보다 먼저 밥과 길을 보네.', + '유비: 헌화, 내가 그 무게를 감당할 수 있을까 두렵소.', + '간옹: 두려움을 아는 사람이면 적어도 함부로 빼앗지는 않겠지. 그러니 먼저 사람을 살피게.' + ], + choices: [ + { + id: 'listen-before-rule', + label: '먼저 백성의 말을 듣는다', + response: '간옹은 유비가 급히 명분을 세우기보다 서주의 목소리를 먼저 듣겠다고 하자 고개를 끄덕였다.', + rewardExp: 8 + }, + { + id: 'appoint-practical-aide', + label: '실무를 맡아 달라 한다', + response: '간옹은 투덜대면서도 군영 장부와 사람들의 청원을 살피겠다고 나섰다.', + rewardExp: 7 + } + ] + }, + { + id: 'liu-mi-zhu-after-xuzhou', + title: '서주의 군량', + availableAfterBattleIds: [campBattleIds.seventh], + unitIds: ['liu-bei', 'mi-zhu'], + bondId: 'liu-bei__mi-zhu', + rewardExp: 18, + lines: [ + '미축: 유 장군, 서주의 창고는 아직 온전하지만 오래 버티려면 상인과 호족의 마음을 함께 묶어야 합니다.', + '유비: 백성에게 더 짐을 지우고 싶지는 않소. 다른 길이 있겠소?', + '미축: 사사로운 이익을 줄이고 공적인 신뢰를 세우면 길은 열립니다. 제가 그 일을 맡겠습니다.' + ], + choices: [ + { + id: 'protect-grain', + label: '군량을 백성과 나눈다', + response: '미축은 군량을 무기로 삼지 않겠다는 유비의 뜻을 확인하고 서주의 상단을 설득하기로 했다.', + rewardExp: 8 + }, + { + id: 'secure-supply-route', + label: '보급로 정비를 부탁한다', + response: '미축은 곧장 장부를 펼쳐 길목과 창고를 다시 묶는 계획을 세웠다.', + rewardExp: 7 + } + ] } ]; @@ -730,6 +785,7 @@ export class CampScene extends Phaser.Scene { this.visitedTabs = new Set(['status']); this.campaign = getCampaignState(); this.report = this.campaign.firstBattleReport ?? getFirstBattleReport() ?? this.createFallbackReport(); + this.ensureCurrentCampRecruitment(); this.selectedUnitId = this.currentUnits().find((unit) => unit.faction === 'ally')?.id ?? 'liu-bei'; this.selectedSortieUnitIds = this.normalizedSortieUnitIds(this.campaign.selectedSortieUnitIds); this.selectedDialogueId = this.availableCampDialogues()[0]?.id ?? campDialogues[0].id; @@ -777,6 +833,15 @@ export class CampScene extends Phaser.Scene { }; } + private ensureCurrentCampRecruitment() { + if (this.currentCampBattleId() !== campBattleIds.seventh || !this.campaign) { + return; + } + + this.campaign = ensureCampaignRosterUnits(xuzhouRecruitUnits, xuzhouRecruitBonds); + this.report = this.campaign.firstBattleReport ?? this.report; + } + private reportSummary() { if (!this.report) { return ''; @@ -947,8 +1012,8 @@ export class CampScene extends Phaser.Scene { this.renderSortieBriefing(x + 34, y + 112, 396, 152, depth + 2); this.renderSortieChecklist(x + 462, y + 112, 404, 170, depth + 2); - this.renderSortieUnitSummary(x + 34, y + 292, 832, 148, depth + 2); - this.renderSortieRewardHint(x + 34, y + 462, 548, 52, depth + 2); + this.renderSortieUnitSummary(x + 34, y + 292, 832, 176, depth + 2); + this.renderSortieRewardHint(x + 34, y + 480, 548, 52, depth + 2); this.addSortieButton('군영으로', x + width - 298, y + height - 44, 124, () => { soundDirector.playSelect(); @@ -1003,16 +1068,18 @@ export class CampScene extends Phaser.Scene { bg.setStrokeStyle(1, palette.blue, 0.48); const allies = this.sortieAllies(); const selectedCount = this.selectedSortieUnitIds.length; + const rowGap = allies.length > 4 ? 24 : 30; + const rowHeight = allies.length > 4 ? 22 : 26; this.trackSortie( this.add.text(x + 18, y + 14, `출전 무장 선택 ${selectedCount}/${Math.min(maxSortieUnits, allies.length)}`, this.textStyle(18, '#f2e3bf', true)) ).setDepth(depth + 1); this.trackSortie(this.add.text(x + width - 18, y + 17, '클릭해서 출전/대기 전환', this.textStyle(12, '#9fb0bf'))).setOrigin(1, 0).setDepth(depth + 1); allies.forEach((unit, index) => { - const rowY = y + 48 + index * 30; + const rowY = y + 48 + index * rowGap; const selected = this.isSortieSelected(unit.id); const required = requiredSortieUnitIds.has(unit.id); - const row = this.trackSortie(this.add.rectangle(x + 18, rowY - 5, width - 36, 26, selected ? 0x172a22 : 0x151b24, selected ? 0.96 : 0.82)); + const row = this.trackSortie(this.add.rectangle(x + 18, rowY - 5, width - 36, rowHeight, selected ? 0x172a22 : 0x151b24, selected ? 0.96 : 0.82)); row.setOrigin(0); row.setDepth(depth + 1); row.setStrokeStyle(1, selected ? palette.green : 0x53606c, selected ? 0.62 : 0.38); @@ -1166,11 +1233,15 @@ export class CampScene extends Phaser.Scene { private renderUnitColumn() { const allies = this.currentUnits().filter((unit) => unit.faction === 'ally'); + const availableHeight = 462; + const rowGap = allies.length > 1 ? Math.min(150, Math.floor(availableHeight / allies.length)) : 150; + const cardHeight = Math.min(126, Math.max(70, rowGap - 10)); + const compact = cardHeight < 104; allies.forEach((unit, index) => { const x = 42; - const y = 120 + index * 150; + const y = 120 + index * rowGap; const active = this.selectedUnitId === unit.id; - const bg = this.track(this.add.rectangle(x, y, 318, 126, active ? 0x25384a : 0x111922, active ? 0.96 : 0.86)); + const bg = this.track(this.add.rectangle(x, y, 318, cardHeight, active ? 0x25384a : 0x111922, active ? 0.96 : 0.86)); bg.setOrigin(0); bg.setStrokeStyle(2, active ? palette.gold : palette.blue, active ? 0.9 : 0.46); bg.setInteractive({ useHandCursor: true }); @@ -1181,18 +1252,32 @@ export class CampScene extends Phaser.Scene { }); const portraitKey = portraitByUnitId[unit.id]; + const portraitCenterY = y + cardHeight / 2; + const portraitSize = compact ? 62 : 92; if (portraitKey) { - const portrait = this.track(this.add.image(x + 58, y + 62, portraitKeys[portraitKey])); - portrait.setDisplaySize(92, 92); + const portrait = this.track(this.add.image(x + 58, portraitCenterY, portraitKeys[portraitKey])); + portrait.setDisplaySize(portraitSize, portraitSize); + } else { + this.renderUnitFallbackPortrait(x + 58, portraitCenterY, compact ? 31 : 44, unit); } - this.track(this.add.text(x + 116, y + 20, `${unit.name} Lv ${unit.level}`, this.textStyle(21, '#f2e3bf', true))); - this.track(this.add.text(x + 116, y + 51, `${unit.className} · HP ${unit.hp}/${unit.maxHp}`, this.textStyle(14, '#d4dce6'))); - this.track(this.add.text(x + 116, y + 78, `경험 ${unit.exp}/100`, this.textStyle(14, '#d8b15f', true))); - this.drawBar(x + 116, y + 104, 176, 8, unit.exp / 100, palette.gold); + const textX = compact ? x + 102 : x + 116; + this.track(this.add.text(textX, y + (compact ? 13 : 20), `${unit.name} Lv ${unit.level}`, this.textStyle(compact ? 17 : 21, '#f2e3bf', true))); + this.track(this.add.text(textX, y + (compact ? 38 : 51), `${unit.className} · HP ${unit.hp}/${unit.maxHp}`, this.textStyle(compact ? 12 : 14, '#d4dce6'))); + this.track(this.add.text(textX, y + (compact ? 58 : 78), `경험 ${unit.exp}/100`, this.textStyle(compact ? 12 : 14, '#d8b15f', true))); + this.drawBar(textX, y + cardHeight - 18, compact ? 160 : 176, 8, unit.exp / 100, palette.gold); }); } + private renderUnitFallbackPortrait(x: number, y: number, radius: number, unit: UnitData) { + const bg = this.track(this.add.circle(x, y, radius, 0x1f3140, 0.96)); + bg.setStrokeStyle(2, palette.gold, 0.58); + const initial = this.track(this.add.text(x, y - radius * 0.34, unit.name.slice(0, 1), this.textStyle(Math.max(17, Math.floor(radius * 0.86)), '#f2e3bf', true))); + initial.setOrigin(0.5); + const label = this.track(this.add.text(x, y + radius * 0.34, unit.className, this.textStyle(Math.max(9, Math.floor(radius * 0.3)), '#d4dce6', true))); + label.setOrigin(0.5); + } + private renderReportPanel() { const report = this.report; if (!report) { @@ -1234,6 +1319,8 @@ export class CampScene extends Phaser.Scene { if (portraitKey) { const portrait = this.track(this.add.image(x + 84, y + 90, portraitKeys[portraitKey])); portrait.setDisplaySize(112, 112); + } else { + this.renderUnitFallbackPortrait(x + 84, y + 90, 48, unit); } this.track(this.add.text(x + 166, y + 22, `${unit.name} Lv ${unit.level}`, this.textStyle(27, '#f2e3bf', true))); @@ -1280,15 +1367,18 @@ export class CampScene extends Phaser.Scene { this.track(this.add.text(x + 24, y + 56, '출진 전 대화에서 선택지를 고르면 해당 장수들의 공명 경험치가 오릅니다.', this.textStyle(14, '#d4dce6'))); const dialogues = this.availableCampDialogues(); + const compactDialogueList = dialogues.length > 3; + const dialogueRowGap = compactDialogueList ? 44 : 64; + const dialogueRowHeight = compactDialogueList ? 36 : 48; if (!dialogues.some((dialogue) => dialogue.id === this.selectedDialogueId)) { this.selectedDialogueId = dialogues[0]?.id ?? campDialogues[0].id; } dialogues.forEach((dialogue, index) => { const completed = this.completedCampDialogues().includes(dialogue.id); - const rowY = y + 96 + index * 64; + const rowY = y + 96 + index * dialogueRowGap; const selected = this.selectedDialogueId === dialogue.id; - const row = this.track(this.add.rectangle(x + 24, rowY, 320, 48, selected ? 0x25384a : 0x151f2a, selected ? 0.96 : 0.86)); + const row = this.track(this.add.rectangle(x + 24, rowY, 320, dialogueRowHeight, selected ? 0x25384a : 0x151f2a, selected ? 0.96 : 0.86)); row.setOrigin(0); row.setStrokeStyle(1, completed ? palette.green : selected ? palette.gold : palette.blue, selected ? 0.72 : 0.46); row.setInteractive({ useHandCursor: true }); @@ -1297,9 +1387,9 @@ export class CampScene extends Phaser.Scene { this.selectedDialogueId = dialogue.id; this.render(); }); - this.track(this.add.text(x + 38, rowY + 8, dialogue.title, this.textStyle(15, completed ? '#a8ffd0' : '#f2e3bf', true))); + this.track(this.add.text(x + 38, rowY + (compactDialogueList ? 5 : 8), dialogue.title, this.textStyle(compactDialogueList ? 13 : 15, completed ? '#a8ffd0' : '#f2e3bf', true))); const maxReward = dialogue.rewardExp + Math.max(...dialogue.choices.map((choice) => choice.rewardExp)); - this.track(this.add.text(x + 38, rowY + 29, `${this.unitName(dialogue.unitIds[0])} · ${this.unitName(dialogue.unitIds[1])} 공명 +${dialogue.rewardExp}~${maxReward}`, this.textStyle(12, '#9fb0bf'))); + this.track(this.add.text(x + 38, rowY + (compactDialogueList ? 22 : 29), `${this.unitName(dialogue.unitIds[0])} · ${this.unitName(dialogue.unitIds[1])} 공명 +${dialogue.rewardExp}~${maxReward}`, this.textStyle(compactDialogueList ? 10 : 12, '#9fb0bf'))); }); this.renderSelectedDialogue(x + 372, y + 96, 416, 300); @@ -1356,12 +1446,15 @@ export class CampScene extends Phaser.Scene { } private renderBondList(x: number, y: number, width: number) { + const bonds = this.currentBonds(); + const compact = bonds.length > 3; + const rowGap = compact ? 25 : 36; this.track(this.add.text(x, y, '공명', this.textStyle(18, '#f2e3bf', true))); - this.currentBonds().forEach((bond, index) => { - const rowY = y + 32 + index * 36; + bonds.forEach((bond, index) => { + const rowY = y + (compact ? 28 : 32) + index * rowGap; const first = this.unitName(bond.unitIds[0]); const second = this.unitName(bond.unitIds[1]); - this.track(this.add.text(x, rowY, `${first}·${second} Lv ${bond.level}`, this.textStyle(13, '#d4dce6'))); + this.track(this.add.text(x, rowY, `${first}·${second} Lv ${bond.level}`, this.textStyle(compact ? 11 : 13, '#d4dce6'))); this.drawBar(x + 136, rowY + 6, width - 136, 7, bond.exp / 100, bond.battleExp > 0 ? palette.gold : palette.blue); }); } diff --git a/src/game/state/campaignState.ts b/src/game/state/campaignState.ts index c88747c..5d159bc 100644 --- a/src/game/state/campaignState.ts +++ b/src/game/state/campaignState.ts @@ -271,6 +271,60 @@ export function applyCampBondExp(dialogueId: string, bondId: string, amount: num return cloneReport(state.firstBattleReport); } +export function ensureCampaignRosterUnits(units: UnitData[], bonds: BattleBond[] = []) { + const state = ensureCampaignState(); + let changed = false; + const existingUnitIds = new Set(state.roster.map((unit) => unit.id)); + + units.forEach((unit) => { + if (existingUnitIds.has(unit.id)) { + return; + } + state.roster.push(cloneUnit(unit)); + existingUnitIds.add(unit.id); + changed = true; + }); + + const existingBondIds = new Set(state.bonds.map((bond) => bond.id)); + bonds.forEach((bond) => { + if (existingBondIds.has(bond.id)) { + return; + } + state.bonds.push(createCampBondSnapshot(bond)); + existingBondIds.add(bond.id); + changed = true; + }); + + if (state.firstBattleReport) { + const reportUnitIds = new Set(state.firstBattleReport.units.map((unit) => unit.id)); + units.forEach((unit) => { + if (reportUnitIds.has(unit.id)) { + return; + } + state.firstBattleReport?.units.push(cloneUnit(unit)); + reportUnitIds.add(unit.id); + changed = true; + }); + + const reportBondIds = new Set(state.firstBattleReport.bonds.map((bond) => bond.id)); + bonds.forEach((bond) => { + if (reportBondIds.has(bond.id)) { + return; + } + state.firstBattleReport?.bonds.push(createCampBondSnapshot(bond)); + reportBondIds.add(bond.id); + changed = true; + }); + } + + if (changed) { + state.updatedAt = new Date().toISOString(); + persistCampaignState(state); + } + + return cloneCampaignState(state); +} + function ensureCampaignState() { if (!campaignState) { campaignState = readStoredCampaignState() ?? createInitialCampaignState(); @@ -433,3 +487,11 @@ function cloneBondSnapshot(bond: CampBondSnapshot): CampBondSnapshot { unitIds: [...bond.unitIds] as [string, string] }; } + +function createCampBondSnapshot(bond: BattleBond): CampBondSnapshot { + return { + ...bond, + unitIds: [...bond.unitIds] as [string, string], + battleExp: 0 + }; +}