From d36b5adeb6532641d587aba35ff1417b9a491792 Mon Sep 17 00:00:00 2001 From: Wickedness Date: Mon, 22 Jun 2026 20:14:09 +0900 Subject: [PATCH] Make camp supplies usable --- scripts/verify-flow.mjs | 29 +++++ src/game/scenes/CampScene.ts | 222 ++++++++++++++++++++++++++++++++--- 2 files changed, 237 insertions(+), 14 deletions(-) diff --git a/scripts/verify-flow.mjs b/scripts/verify-flow.mjs index 6eab1d0..dec54b4 100644 --- a/scripts/verify-flow.mjs +++ b/scripts/verify-flow.mjs @@ -203,6 +203,35 @@ try { throw new Error(`Expected CampScene report after victory: ${JSON.stringify(campState)}`); } + await page.evaluate(() => { + const campScene = window.__HEROS_GAME__?.scene.getScene('CampScene'); + const liuBei = campScene?.campaign?.roster?.find((unit) => unit.id === 'liu-bei'); + if (!liuBei) { + throw new Error('Expected Liu Bei in camp roster before supply verification.'); + } + liuBei.hp = Math.max(1, liuBei.maxHp - 10); + campScene.report?.units?.forEach((unit) => { + if (unit.id === 'liu-bei') { + unit.hp = liuBei.hp; + } + }); + campScene.render(); + }); + await page.mouse.click(918, 38); + await page.waitForTimeout(160); + await page.screenshot({ path: 'dist/verification-camp-supplies.png', fullPage: true }); + await page.mouse.click(1120, 245); + await page.waitForTimeout(260); + + const campStateAfterSupply = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + const liuBeiAfterSupply = campStateAfterSupply?.campaign?.roster?.find((unit) => unit.id === 'liu-bei'); + if (!liuBeiAfterSupply || liuBeiAfterSupply.hp !== liuBeiAfterSupply.maxHp) { + throw new Error(`Expected bean supply to recover Liu Bei: ${JSON.stringify(campStateAfterSupply)}`); + } + if ((campStateAfterSupply.campaign?.inventory?.['콩'] ?? 0) !== 0) { + throw new Error(`Expected bean supply to be consumed: ${JSON.stringify(campStateAfterSupply.campaign?.inventory)}`); + } + await page.mouse.click(830, 38); await page.waitForTimeout(120); await page.mouse.click(974, 482); diff --git a/src/game/scenes/CampScene.ts b/src/game/scenes/CampScene.ts index 97208b0..da659ec 100644 --- a/src/game/scenes/CampScene.ts +++ b/src/game/scenes/CampScene.ts @@ -32,6 +32,14 @@ type CampTabButtonView = { text: Phaser.GameObjects.Text; }; +type CampSupplyDefinition = { + label: string; + title: string; + description: string; + healHp: number; + bondExp: number; +}; + const portraitKeys: Record = { liuBei: 'portrait-liu-bei', guanYu: 'portrait-guan-yu', @@ -44,6 +52,25 @@ const portraitByUnitId: Record = { 'zhang-fei': 'zhangFei' }; +const campSupplies: CampSupplyDefinition[] = [ + { + label: '콩', + title: '콩', + description: '선택 장수의 병력을 12 회복합니다.', + healHp: 12, + bondExp: 0 + }, + { + label: '탁주', + title: '탁주', + description: '선택 장수의 병력을 8 회복하고 연결된 공명 경험치를 2 올립니다.', + healHp: 8, + bondExp: 2 + } +]; + +const campSupplyByLabel = new Map(campSupplies.map((supply) => [supply.label, supply])); + const campDialogues: CampDialogue[] = [ { id: 'liu-guan-after-first-battle', @@ -433,24 +460,39 @@ export class CampScene extends Phaser.Scene { bg.setOrigin(0); bg.setStrokeStyle(1, palette.blue, 0.56); this.track(this.add.text(x + 24, y + 22, '정비와 보급', this.textStyle(24, '#f2e3bf', true))); - this.track(this.add.text(x + 24, y + 58, '획득한 보상과 장비 성장 상태를 확인합니다. 장비 교체와 상점은 다음 확장에서 붙일 수 있습니다.', this.textStyle(14, '#d4dce6'))); + this.track(this.add.text(x + 24, y + 58, '소모품은 왼쪽에서 선택한 장수에게 사용됩니다.', this.textStyle(14, '#d4dce6'))); - const rewards = this.inventoryLabels(); - rewards.forEach((reward, index) => { - this.renderSupplyBox(reward, x + 24 + index * 160, y + 104); + const unit = this.selectedUnit(); + if (unit) { + this.renderSupplyTargetCard(unit, x + 24, y + 94, 342, 86); + } + + campSupplies.forEach((supply, index) => { + this.renderSupplyUseRow(supply, x + 390, y + 94 + index * 74, 390); }); - this.track(this.add.text(x + 24, y + 190, '부대 장비 요약', this.textStyle(20, '#f2e3bf', true))); + this.track(this.add.text(x + 24, y + 202, '전리품과 명성', this.textStyle(19, '#f2e3bf', true))); + const trophies = this.nonConsumableInventoryLabels(); + if (trophies.length === 0) { + this.track(this.add.text(x + 24, y + 236, '소모품 외 전리품 없음', this.textStyle(13, '#9fb0bf'))); + } else { + trophies.forEach((reward, index) => { + this.renderSupplyBox(reward, x + 24 + index * 154, y + 232); + }); + } + + this.track(this.add.text(x + 24, y + 306, '부대 장비 요약', this.textStyle(19, '#f2e3bf', true))); this.currentUnits() .filter((unit) => unit.faction === 'ally') .forEach((unit, index) => { - const rowY = y + 232 + index * 58; - this.track(this.add.text(x + 24, rowY, unit.name, this.textStyle(17, '#f2e3bf', true))); + const rowY = y + 342 + index * 30; + this.track(this.add.text(x + 24, rowY, unit.name, this.textStyle(14, '#f2e3bf', true))); equipmentSlots.forEach((slot, slotIndex) => { const state = unit.equipment[slot]; const item = getItem(state.itemId); - this.track(this.add.text(x + 110 + slotIndex * 218, rowY, `${equipmentSlotLabels[slot]} ${item.name}`, this.textStyle(13, '#d4dce6'))); - this.drawBar(x + 110 + slotIndex * 218, rowY + 24, 176, 6, state.exp / equipmentExpToNext(state.level), item.rank === 'treasure' ? palette.gold : palette.blue); + const slotX = x + 104 + slotIndex * 220; + this.track(this.add.text(slotX, rowY - 2, `${equipmentSlotLabels[slot]} ${item.name} Lv${state.level}`, this.textStyle(11, '#d4dce6'))); + this.drawBar(slotX, rowY + 17, 176, 6, state.exp / equipmentExpToNext(state.level), item.rank === 'treasure' ? palette.gold : palette.blue); }); }); } @@ -462,6 +504,135 @@ export class CampScene extends Phaser.Scene { this.track(this.add.text(x + 14, y + 15, label, this.textStyle(16, '#f2e3bf', true))); } + private renderSupplyTargetCard(unit: UnitData, x: number, y: number, width: number, height: number) { + const bg = this.track(this.add.rectangle(x, y, width, height, 0x0d141c, 0.92)); + bg.setOrigin(0); + bg.setStrokeStyle(1, palette.gold, 0.48); + this.track(this.add.text(x + 16, y + 12, '사용 대상', this.textStyle(15, '#f2e3bf', true))); + this.track(this.add.text(x + 16, y + 38, `${unit.name} ${unit.className} Lv ${unit.level}`, this.textStyle(16, '#d4dce6', true))); + this.track(this.add.text(x + 16, y + 62, `병력 ${unit.hp}/${unit.maxHp}`, this.textStyle(13, '#9fb0bf', true))); + this.drawBar(x + 118, y + 68, width - 138, 8, unit.hp / unit.maxHp, palette.green); + } + + private renderSupplyUseRow(supply: CampSupplyDefinition, x: number, y: number, width: number) { + const unit = this.selectedUnit(); + const amount = this.inventoryAmount(supply.label); + const enabled = Boolean(unit && this.canUseSupply(supply, unit)); + const bg = this.track(this.add.rectangle(x, y, width, 62, enabled ? 0x151f2a : 0x111820, enabled ? 0.92 : 0.76)); + bg.setOrigin(0); + bg.setStrokeStyle(1, enabled ? palette.gold : 0x53606c, enabled ? 0.62 : 0.38); + + this.track(this.add.text(x + 14, y + 10, `${supply.title} x${amount}`, this.textStyle(16, amount > 0 ? '#f2e3bf' : '#7f8994', true))); + this.track( + this.add.text(x + 14, y + 34, supply.description, { + ...this.textStyle(12, enabled ? '#c8d2dd' : '#77818c'), + wordWrap: { width: width - 128, useAdvancedWrap: true } + }) + ); + + const button = this.track(this.add.rectangle(x + width - 54, y + 31, 82, 30, enabled ? 0x1a2630 : 0x121922, enabled ? 0.96 : 0.72)); + button.setStrokeStyle(1, enabled ? palette.gold : 0x53606c, enabled ? 0.76 : 0.42); + const buttonText = this.track(this.add.text(x + width - 54, y + 31, enabled ? '사용' : '불가', this.textStyle(13, enabled ? '#f2e3bf' : '#7f8994', true))); + buttonText.setOrigin(0.5); + + if (enabled) { + const action = () => this.useSupply(supply); + button.setInteractive({ useHandCursor: true }); + button.on('pointerover', () => button.setFillStyle(0x283947, 0.98)); + button.on('pointerout', () => button.setFillStyle(0x1a2630, 0.96)); + button.on('pointerdown', action); + buttonText.setInteractive({ useHandCursor: true }); + buttonText.on('pointerdown', action); + } + } + + private canUseSupply(supply: CampSupplyDefinition, unit: UnitData) { + if (this.inventoryAmount(supply.label) <= 0) { + return false; + } + if (supply.healHp > 0 && unit.hp < unit.maxHp) { + return true; + } + return supply.bondExp > 0 && this.currentBonds().some((bond) => bond.unitIds.includes(unit.id)); + } + + private useSupply(supply: CampSupplyDefinition) { + const campaign = this.campaign ?? getCampaignState(); + const target = campaign.roster.find((unit) => unit.id === this.selectedUnitId); + if (!target) { + this.showCampNotice('사용할 장수를 선택하세요.'); + return; + } + + if ((campaign.inventory[supply.label] ?? 0) <= 0) { + this.showCampNotice(`${supply.title}이 없습니다.`); + return; + } + + const previousHp = target.hp; + target.hp = Math.min(target.maxHp, target.hp + supply.healHp); + const hpGain = target.hp - previousHp; + const bondGain = this.applySupplyBondExp(campaign, target.id, supply.bondExp); + + if (hpGain <= 0 && bondGain <= 0) { + this.showCampNotice(`${target.name}에게 지금은 사용할 필요가 없습니다.`); + return; + } + + campaign.inventory[supply.label] -= 1; + if (campaign.inventory[supply.label] <= 0) { + delete campaign.inventory[supply.label]; + } + this.syncReportUnitHp(campaign, target.id, target.hp); + + this.campaign = saveCampaignState(campaign); + this.report = this.campaign.firstBattleReport ?? this.report; + soundDirector.playEffect('exp-gain', { volume: 0.24, stopAfterMs: 620 }); + + const hpText = hpGain > 0 ? `병력 +${hpGain}` : ''; + const bondText = bondGain > 0 ? `공명 +${supply.bondExp}` : ''; + this.showCampNotice(`${target.name} ${supply.title} 사용 · ${[hpText, bondText].filter(Boolean).join(' / ')}`); + this.render(); + } + + private applySupplyBondExp(campaign: CampaignState, unitId: string, amount: number) { + if (amount <= 0) { + return 0; + } + + let affected = 0; + campaign.bonds + .filter((bond) => bond.unitIds.includes(unitId)) + .forEach((bond) => { + this.advanceCampBond(bond, amount); + affected += 1; + const reportBond = campaign.firstBattleReport?.bonds.find((candidate) => candidate.id === bond.id); + if (reportBond) { + reportBond.level = bond.level; + reportBond.exp = bond.exp; + reportBond.battleExp = bond.battleExp; + } + }); + + return affected * amount; + } + + private advanceCampBond(bond: CampaignState['bonds'][number], amount: number) { + bond.battleExp += amount; + bond.exp += amount; + while (bond.exp >= 100) { + bond.exp -= 100; + bond.level = Math.min(100, bond.level + 1); + } + } + + private syncReportUnitHp(campaign: CampaignState, unitId: string, hp: number) { + const reportUnit = campaign.firstBattleReport?.units.find((unit) => unit.id === unitId); + if (reportUnit) { + reportUnit.hp = hp; + } + } + private renderDetailGauge(label: string, value: string, ratio: number, x: number, y: number, width: number, color: number) { this.track(this.add.text(x, y - 2, label, this.textStyle(13, '#9fb0bf', true))); this.drawBar(x + 54, y + 6, width - 134, 8, ratio, color); @@ -620,6 +791,23 @@ export class CampScene extends Phaser.Scene { return this.report?.itemRewards.length ? this.report.itemRewards : ['콩 1', '탁주 1']; } + private nonConsumableInventoryLabels() { + const entries = Object.entries(this.campaign?.inventory ?? {}).filter(([label, amount]) => { + return amount > 0 && !campSupplyByLabel.has(label); + }); + if (entries.length > 0) { + return entries.map(([label, amount]) => `${label} ${amount}`); + } + return this.inventoryLabels().filter((label) => { + const normalized = label.replace(/\s+\d+$/, '').trim(); + return !campSupplyByLabel.has(normalized); + }); + } + + private inventoryAmount(label: string) { + return this.campaign?.inventory[label] ?? 0; + } + private drawBar(x: number, y: number, width: number, height: number, ratio: number, color: number) { const track = this.track(this.add.rectangle(x, y, width, height, 0x070b10, 0.86)); track.setOrigin(0); @@ -654,11 +842,17 @@ export class CampScene extends Phaser.Scene { selectedDialogueId: this.selectedDialogueId, campaign: this.campaign ? { - step: this.campaign.step, - gold: this.campaign.gold, - inventory: this.campaign.inventory, - completedCampDialogues: this.campaign.completedCampDialogues - } + step: this.campaign.step, + gold: this.campaign.gold, + inventory: this.campaign.inventory, + roster: this.campaign.roster.map((unit) => ({ + id: unit.id, + name: unit.name, + hp: unit.hp, + maxHp: unit.maxHp + })), + completedCampDialogues: this.campaign.completedCampDialogues + } : null, report: this.report ? {