From 16738e74c2f1fe834b541e20cbad20c15a6e895f Mon Sep 17 00:00:00 2001 From: Wickedness Date: Fri, 26 Jun 2026 23:32:29 +0900 Subject: [PATCH] Polish Wuzhang ending QA flow --- scripts/verify-flow.mjs | 29 +++++++++-- src/game/data/campaignFlow.ts | 14 +++--- src/game/data/scenario.ts | 8 +-- src/game/scenes/CampScene.ts | 91 ++++++++++++++++++++++++++++++++--- 4 files changed, 122 insertions(+), 20 deletions(-) diff --git a/scripts/verify-flow.mjs b/scripts/verify-flow.mjs index c378094..ee95461 100644 --- a/scripts/verify-flow.mjs +++ b/scripts/verify-flow.mjs @@ -9792,7 +9792,7 @@ function assertSortieTacticalRoster(state, requiredUnitIds) { } async function clickSortieRosterUnit(page, unitId) { - const state = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + let state = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if (!state?.sortieVisible || !Array.isArray(state.sortieRoster)) { throw new Error(`Cannot click sortie unit ${unitId}; sortie roster is not visible: ${JSON.stringify(state)}`); } @@ -9803,8 +9803,31 @@ async function clickSortieRosterUnit(page, unitId) { } const denseRoster = state.sortieRoster.length > 12; - const rowGap = denseRoster ? 17 : state.sortieRoster.length > 7 ? 30 : state.sortieRoster.length > 5 ? 38 : state.sortieRoster.length > 4 ? 43 : 48; - await page.mouse.click(254, 314 + (denseRoster ? 48 : 50) + index * rowGap); + const rowGap = + state.sortieRosterView?.rowGap ?? + (denseRoster ? 17 : state.sortieRoster.length > 7 ? 30 : state.sortieRoster.length > 5 ? 38 : state.sortieRoster.length > 4 ? 43 : 48); + const listTopOffset = state.sortieRosterView?.listTopOffset ?? (denseRoster ? 48 : 50); + const maxVisibleRows = state.sortieRosterView?.maxVisibleRows ?? state.sortieRoster.length; + const rosterX = 254; + const rosterY = 314 + listTopOffset; + let scroll = state.sortieRosterView?.scroll ?? 0; + let guard = 0; + + while (index < scroll || index >= scroll + maxVisibleRows) { + const direction = index < scroll ? -1 : 1; + const pointerRow = Math.max(0, Math.min(maxVisibleRows - 1, index - scroll)); + await page.mouse.move(rosterX, rosterY + pointerRow * rowGap); + await page.mouse.wheel(0, direction * 360); + await page.waitForTimeout(80); + state = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + const nextScroll = state?.sortieRosterView?.scroll ?? scroll; + if (nextScroll === scroll || guard++ > 20) { + throw new Error(`Cannot scroll sortie roster to ${unitId}: ${JSON.stringify(state?.sortieRosterView)}`); + } + scroll = nextScroll; + } + + await page.mouse.click(rosterX, rosterY + (index - scroll) * rowGap); await page.waitForTimeout(120); } diff --git a/src/game/data/campaignFlow.ts b/src/game/data/campaignFlow.ts index 206dc34..3d1a903 100644 --- a/src/game/data/campaignFlow.ts +++ b/src/game/data/campaignFlow.ts @@ -968,14 +968,14 @@ const sortieFlows: Record = { }, [sixtyFifthBattleScenario.id]: { afterBattleId: sixtyFifthBattleScenario.id, - eyebrow: '최종 후일담', - title: '북벌의 끝과 남은 뜻', + eyebrow: '다음 전장', + title: sixtySixthBattleScenario.title, description: - '위수 북안 지연선을 누른 뒤, 촉한군은 더 깊은 전장보다 살아 돌아갈 길과 이어질 뜻을 정리합니다. 마지막 후일담은 새 전투가 아니라 제갈량과 강유, 남은 장수들이 북벌의 의미를 정리하는 장면입니다.', - rewardHint: '최종 보상: 북벌 후일담 / 엔딩 개방', - campaignStep: 'ending-complete', - pages: [...sixtyFifthBattleVictoryPages, ...endingEpiloguePages], - unavailableNotice: '최종 후일담과 엔딩을 이미 확인했습니다. 타이틀에서 이어하기를 선택하면 엔딩 화면으로 돌아갑니다.' + '위수 북안 압박 후 촉한군은 오장원 본영으로 돌아와 마지막 장부를 정리합니다. 이번 출전은 전공을 더하는 전투가 아니라, 제갈량의 명령과 강유의 계승, 병사들의 귀환로를 함께 지켜 내는 최종장입니다.', + rewardHint: `예상 보상: ${sixtySixthBattleScenario.title} 개방 / 오장원 귀환 장부`, + nextBattleId: sixtySixthBattleScenario.id, + campaignStep: 'sixty-sixth-battle', + pages: [...sixtyFifthBattleVictoryPages, ...sixtySixthBattleIntroPages] }, [sixtySixthBattleScenario.id]: { afterBattleId: sixtySixthBattleScenario.id, diff --git a/src/game/data/scenario.ts b/src/game/data/scenario.ts index 6e83152..c3a8626 100644 --- a/src/game/data/scenario.ts +++ b/src/game/data/scenario.ts @@ -4176,10 +4176,10 @@ export const endingEpiloguePages: StoryPage[] = [ { id: 'ending-wuzhang-council', bgm: 'story-dark', - chapter: '오장원의 밤', + chapter: '불 꺼진 본영', background: 'story-weishui-northbank', - speaker: '제갈량', - text: '위수의 바람은 오래 불었고, 북방의 길은 한 사람의 뜻만으로 끝낼 수 없을 만큼 멀었습니다. 승상은 남은 장수들을 불러 더 밀고 들어갈 길보다 돌아갈 병사와 지킬 나라를 먼저 적었습니다.' + speaker: '내레이션', + text: '오장원의 불빛이 사라진 뒤에도 장부의 마지막 줄은 남았습니다. 남은 장수들은 더 밀고 들어갈 길보다 돌아갈 병사와 지킬 나라를 먼저 적었고, 승상의 뜻은 다음 사람이 이어 받을 약속이 되었습니다.' }, { id: 'ending-jiang-wei-inherits-map', @@ -4202,7 +4202,7 @@ export const endingEpiloguePages: StoryPage[] = [ bgm: 'oath-theme', chapter: '촉한의 뜻', background: 'story-yiling-baidi', - speaker: '나레이션', + speaker: '내레이션', text: '도원에서 시작된 맹세는 전장을 지나 나라가 되었고, 나라는 다시 사람들의 기억이 되었습니다. 촉한의 북벌은 끝을 보지 못했으나, 의와 선택으로 버틴 시간은 다음 이야기가 설 자리를 남겼습니다.' } ]; diff --git a/src/game/scenes/CampScene.ts b/src/game/scenes/CampScene.ts index 1a85224..e4cda5c 100644 --- a/src/game/scenes/CampScene.ts +++ b/src/game/scenes/CampScene.ts @@ -466,7 +466,7 @@ const campaignTimelineChapters: CampaignTimelineChapter[] = [ 'sixty-fifth-battle-weishui-northbank', 'sixty-sixth-battle-wuzhang-final' ], - nextHints: ['한중 창고 정비', '출전 무장 재편', '기산 출진로', '천수·가정 방면', '강유 합류', '진창 공성 재계산', '무도·음평 확보', '한중 우로 방어', '기산 재출정', '노성 추격', '위수 진영', '위수 북안'] + nextHints: ['한중 창고 정비', '출전 무장 재편', '기산 출진로', '천수·가정 방면', '강유 합류', '진창 공성 재계산', '무도·음평 확보', '한중 우로 방어', '기산 재출정', '노성 추격', '위수 진영', '위수 북안', '오장원 최종전'] } ]; @@ -10007,6 +10007,7 @@ export class CampScene extends Phaser.Scene { private visitedTabs = new Set(); private selectedSortieUnitIds: string[] = []; private sortieFocusedUnitId = 'liu-bei'; + private sortieRosterScroll = 0; private terrainCountCache = new Map(); constructor() { @@ -11260,16 +11261,35 @@ export class CampScene extends Phaser.Scene { const allies = this.sortieAllies(); const selectedCount = this.selectedSortieUnitIds.length; const maxUnits = this.sortieMaxUnits(); - const denseRoster = allies.length > 12; - const rowGap = denseRoster ? 17 : allies.length > 7 ? 30 : allies.length > 5 ? 38 : allies.length > 4 ? 43 : 48; - const rowHeight = denseRoster ? 15 : allies.length > 7 ? 24 : rowGap - 6; + const rosterView = this.sortieRosterViewState(allies.length, height); + const { denseRoster, rowGap, rowHeight, listTopOffset, listFooterHeight, maxVisibleRows, maxScroll } = rosterView; + this.sortieRosterScroll = Phaser.Math.Clamp(this.sortieRosterScroll, 0, maxScroll); + const visibleAllies = allies.slice(this.sortieRosterScroll, this.sortieRosterScroll + maxVisibleRows); + const handleRosterWheel = ( + _pointer: Phaser.Input.Pointer, + _deltaX: number, + deltaY: number, + _deltaZ: number, + event?: { stopPropagation: () => void } + ) => { + event?.stopPropagation(); + const direction = Math.sign(deltaY); + if (direction === 0 || maxScroll <= 0) { + return; + } + this.updateSortieRosterScroll(this.sortieRosterScroll + direction * 3, maxScroll); + }; + if (maxScroll > 0) { + bg.setInteractive({ useHandCursor: false }); + bg.on('wheel', handleRosterWheel); + } this.trackSortie( this.add.text(x + 18, y + 14, `출전 무장 선택 ${selectedCount}/${maxUnits}`, 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 + (denseRoster ? 48 : 50) + index * rowGap; + 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); @@ -11280,6 +11300,7 @@ export class CampScene extends Phaser.Scene { row.setDepth(depth + 1); row.setStrokeStyle(1, selected ? palette.green : 0x53606c, selected ? 0.62 : 0.38); row.setInteractive({ useHandCursor: true }); + row.on('wheel', handleRosterWheel); row.on('pointerover', () => { this.sortieFocusedUnitId = unit.id; }); @@ -11303,6 +11324,63 @@ export class CampScene extends Phaser.Scene { this.trackSortie(this.add.text(x + 610, rowY + 18, this.compactText(summary.bondLine, 15), this.textStyle(11, selected ? '#a8ffd0' : '#68727e', selected))).setDepth(depth + 2); } }); + + if (maxScroll > 0) { + const rangeEnd = this.sortieRosterScroll + visibleAllies.length; + const info = this.trackSortie( + this.add.text( + x + width - 18, + y + height - 20, + `${this.sortieRosterScroll + 1}-${rangeEnd}/${allies.length} · 휠로 이동`, + this.textStyle(11, '#9fb0bf', true) + ) + ); + info.setOrigin(1, 0); + info.setDepth(depth + 1); + + const trackX = x + width - 12; + const trackY = y + listTopOffset; + const trackHeight = height - listFooterHeight; + 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 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); + scrollThumb.setDepth(depth + 2); + } + } + + private updateSortieRosterScroll(nextScroll: number, maxScroll: number) { + const clamped = Phaser.Math.Clamp(Math.round(nextScroll), 0, maxScroll); + if (clamped === this.sortieRosterScroll) { + return; + } + this.sortieRosterScroll = clamped; + this.showSortiePrep(); + } + + private sortieRosterViewState(totalUnits: number, height = 286) { + const denseRoster = totalUnits > 12; + const rowGap = denseRoster ? 17 : totalUnits > 7 ? 30 : totalUnits > 5 ? 38 : totalUnits > 4 ? 43 : 48; + const rowHeight = denseRoster ? 15 : totalUnits > 7 ? 24 : rowGap - 6; + const listTopOffset = denseRoster ? 48 : 50; + const listFooterHeight = denseRoster ? 74 : 68; + const naturalVisibleRows = Math.max(1, Math.floor((height - listTopOffset - 10 - rowHeight) / rowGap) + 1); + const scrollVisibleRows = Math.max(1, Math.floor((height - listFooterHeight) / rowGap)); + const maxVisibleRows = totalUnits <= naturalVisibleRows ? totalUnits : scrollVisibleRows; + const maxScroll = Math.max(0, totalUnits - maxVisibleRows); + return { + denseRoster, + rowGap, + rowHeight, + listTopOffset, + listFooterHeight, + maxVisibleRows, + maxScroll, + scroll: Phaser.Math.Clamp(this.sortieRosterScroll, 0, maxScroll) + }; } private renderSortieFormationPlan(x: number, y: number, width: number, height: number, depth: number) { @@ -13201,6 +13279,7 @@ export class CampScene extends Phaser.Scene { sortieFocusedUnit: this.sortieFocusedUnitSummary(), reserveTrainingFocus: this.reserveTrainingFocusDefinition(), reserveTrainingFocusOptions: campaignReserveTrainingFocusDefinitions.map((focus) => ({ ...focus })), + sortieRosterView: this.sortieRosterViewState(this.sortieAllies().length), sortieRoster: this.sortieAllies().map((unit) => { const summary = this.sortieUnitTacticalSummary(unit); return {