diff --git a/scripts/verify-flow.mjs b/scripts/verify-flow.mjs index 6e74974..3b8225a 100644 --- a/scripts/verify-flow.mjs +++ b/scripts/verify-flow.mjs @@ -99,7 +99,7 @@ try { throw new Error(`Expected a scrollable battle map: ${JSON.stringify(cameraBeforeScroll)}`); } - await page.mouse.move(768, 360); + await page.mouse.move(872, 360); await page.waitForTimeout(520); const cameraAfterEdgeScroll = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()?.camera); if (!cameraAfterEdgeScroll || cameraAfterEdgeScroll.x <= cameraBeforeScroll.x) { diff --git a/src/game/data/scenario.ts b/src/game/data/scenario.ts index f773b3c..83a565a 100644 --- a/src/game/data/scenario.ts +++ b/src/game/data/scenario.ts @@ -141,6 +141,50 @@ export const prologuePages: StoryPage[] = [ } ]; +export const firstBattleVictoryPages: StoryPage[] = [ + { + id: 'first-victory-village', + bgm: 'militia-theme', + chapter: '첫 승리', + background: 'story-militia', + text: '황건적의 깃발이 쓰러지자, 숨어 있던 백성들이 하나둘 마을 어귀로 돌아왔다.' + }, + { + id: 'first-victory-liu-bei', + bgm: 'militia-theme', + chapter: '첫 승리', + background: 'story-liu-bei', + speaker: '유비', + portrait: 'liuBei', + text: '오늘의 승리는 작다. 그러나 백성 한 사람을 지키는 일에서 천하를 바로잡는 뜻이 시작된다.' + }, + { + id: 'first-victory-guan-yu', + bgm: 'militia-theme', + chapter: '의용군의 이름', + background: 'story-three-heroes', + speaker: '관우', + portrait: 'guanYu', + text: '형님의 뜻을 보고 사람들이 모일 것입니다. 다음 싸움은 오늘보다 더 큰 책임을 요구하겠지요.' + }, + { + id: 'first-victory-zhang-fei', + bgm: 'militia-theme', + chapter: '의용군의 이름', + background: 'story-militia', + speaker: '장비', + portrait: 'zhangFei', + text: '하하! 그럼 더 크게 외쳐야겠소. 창을 들 자는 모여라! 우리 의용군은 물러서지 않는다!' + }, + { + id: 'first-victory-next', + bgm: 'battle-prep', + chapter: '다음 길', + background: 'story-sortie', + text: '세 형제의 이름은 탁현 일대에 퍼지기 시작했다. 이제 더 넓은 전장과 더 많은 인연이 그들을 기다린다.' + } +]; + export const firstBattleMap: BattleMap = { width: 20, height: 18, diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index be1c073..7081cb5 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -4,6 +4,7 @@ import { firstBattleBonds, firstBattleMap, firstBattleUnits, + firstBattleVictoryPages, type BattleBond, type UnitData, type UnitStats @@ -523,9 +524,10 @@ export class BattleScene extends Phaser.Scene { const panelWidth = Phaser.Math.Clamp(Math.floor(width * 0.27), 320, 360); const mapWidth = width - panelWidth - gap - margin * 2; const mapHeight = height - margin * 2; - const visibleColumns = Math.min(12, firstBattleMap.width); const visibleRows = Math.min(12, firstBattleMap.height); - const tileSize = Math.floor(Math.min((mapHeight - 36) / visibleRows, (mapWidth - 72) / visibleColumns)); + const rowBasedTileSize = Math.floor((mapHeight - 36) / visibleRows); + const visibleColumns = Math.min(firstBattleMap.width, Math.max(12, Math.floor((mapWidth - 16) / rowBasedTileSize))); + const tileSize = Math.floor(Math.min((mapHeight - 36) / visibleRows, (mapWidth - 16) / visibleColumns)); const gridWidth = tileSize * visibleColumns; const gridHeight = tileSize * visibleRows; const gridSize = Math.min(gridWidth, gridHeight); @@ -1662,8 +1664,8 @@ export class BattleScene extends Phaser.Scene { this.hideBattleResult(); const depth = 120; - const panelWidth = 920; - const panelHeight = 590; + const panelWidth = 980; + const panelHeight = 620; const left = Math.floor((this.scale.width - panelWidth) / 2); const top = Math.floor((this.scale.height - panelHeight) / 2); const allies = firstBattleUnits.filter((unit) => unit.faction === 'ally'); @@ -1706,14 +1708,14 @@ export class BattleScene extends Phaser.Scene { subtitle.setDepth(depth + 2); const metricTop = top + 112; - const metricWidth = 196; + const metricWidth = 210; const metricGap = 18; this.renderResultMetric('소요 턴', `${this.turnNumber}턴`, left + 44, metricTop, metricWidth, depth + 2); this.renderResultMetric('생존 아군', `${aliveAllies} / ${allies.length}`, left + 44 + (metricWidth + metricGap), metricTop, metricWidth, depth + 2); this.renderResultMetric('격파 적군', `${defeatedEnemies} / ${totalEnemies}`, left + 44 + (metricWidth + metricGap) * 2, metricTop, metricWidth, depth + 2); - this.renderResultMetric('전투 보상', outcome === 'victory' ? '획득' : '없음', left + 44 + (metricWidth + metricGap) * 3, metricTop, metricWidth, depth + 2); + this.renderResultMetric('전투 보상', outcome === 'victory' ? '군자금 300' : '없음', left + 44 + (metricWidth + metricGap) * 3, metricTop, metricWidth, depth + 2); - const unitTitle = this.trackResultObject(this.add.text(left + 44, top + 178, '장수 성장', { + const unitTitle = this.trackResultObject(this.add.text(left + 44, top + 184, '장수 성장', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '21px', color: '#f2e3bf', @@ -1722,10 +1724,10 @@ export class BattleScene extends Phaser.Scene { unitTitle.setDepth(depth + 2); allies.forEach((unit, index) => { - this.renderResultUnitRow(unit, left + 44, top + 214 + index * 66, panelWidth - 88, depth + 2); + this.renderResultUnitRow(unit, left + 44, top + 220 + index * 72, panelWidth - 88, depth + 2); }); - const bondTitleTop = top + 424; + const bondTitleTop = top + 452; const bondTitle = this.trackResultObject(this.add.text(left + 44, bondTitleTop, '공명 변화', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '21px', @@ -1738,24 +1740,17 @@ export class BattleScene extends Phaser.Scene { this.renderResultBondRow(bond, left + 44 + index * 278, bondTitleTop + 38, 258, depth + 2); }); - const rewardLines = - outcome === 'victory' - ? ['군자금 300', '마을 민심 +1', '의용군 명망 +1'] - : ['보상 없음', '유비를 보호하며 다시 진군하세요.']; - const reward = this.trackResultObject(this.add.text(left + panelWidth - 44, top + 520, rewardLines.join(' '), { - fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '15px', - color: outcome === 'victory' ? '#d8b15f' : '#aeb7c2', - fontStyle: '700' - })); - reward.setOrigin(1, 0); - reward.setDepth(depth + 2); - - this.addResultButton('다시 하기', left + panelWidth - 286, top + 552, 124, () => { + if (outcome === 'victory') { + this.addResultButton('이야기 계속', left + panelWidth - 422, top + 572, 132, () => { + soundDirector.playSelect(); + this.scene.start('StoryScene', { pages: firstBattleVictoryPages, nextScene: 'TitleScene' }); + }, depth + 3); + } + this.addResultButton('다시 하기', left + panelWidth - 276, top + 572, 124, () => { soundDirector.playSelect(); this.scene.restart(); }, depth + 3); - this.addResultButton('타이틀로', left + panelWidth - 150, top + 552, 124, () => { + this.addResultButton('타이틀로', left + panelWidth - 140, top + 572, 124, () => { soundDirector.playSelect(); this.scene.start('TitleScene'); }, depth + 3); @@ -1791,7 +1786,7 @@ export class BattleScene extends Phaser.Scene { private renderResultUnitRow(unit: UnitData, x: number, y: number, width: number, depth: number) { const alive = unit.hp > 0; - const bg = this.trackResultObject(this.add.rectangle(x, y, width, 56, 0x101820, alive ? 0.94 : 0.66)); + const bg = this.trackResultObject(this.add.rectangle(x, y, width, 64, 0x101820, alive ? 0.94 : 0.66)); bg.setOrigin(0); bg.setDepth(depth); bg.setStrokeStyle(1, alive ? palette.blue : 0x646464, alive ? 0.62 : 0.5); @@ -1819,7 +1814,7 @@ export class BattleScene extends Phaser.Scene { })); hpText.setDepth(depth + 1); - const expText = this.trackResultObject(this.add.text(x + 392, y + 9, `경험 ${unit.exp}/100`, { + const expText = this.trackResultObject(this.add.text(x + 392, y + 9, this.characterResultText(unit), { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '14px', color: '#d8b15f', @@ -1827,14 +1822,7 @@ export class BattleScene extends Phaser.Scene { })); expText.setDepth(depth + 1); - const equipmentText = equipmentSlots - .map((slot) => { - const state = unit.equipment[slot]; - const item = getItem(state.itemId); - return `${equipmentSlotLabels[slot]} ${item.name} Lv${state.level} ${state.exp}/${equipmentExpToNext(state.level)}`; - }) - .join(' '); - const equipment = this.trackResultObject(this.add.text(x + 14, y + 32, equipmentText, { + const equipment = this.trackResultObject(this.add.text(x + 14, y + 32, this.equipmentResultText(unit), { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '12px', color: alive ? '#c8d2dd' : '#7e858d', @@ -1843,6 +1831,41 @@ export class BattleScene extends Phaser.Scene { equipment.setDepth(depth + 1); } + private characterResultText(unit: UnitData) { + const initial = initialFirstBattleUnits.find((candidate) => candidate.id === unit.id); + const gained = initial ? Math.max(0, this.characterProgressScore(unit.level, unit.exp) - this.characterProgressScore(initial.level, initial.exp)) : 0; + const levelDelta = initial ? unit.level - initial.level : 0; + const levelText = levelDelta > 0 ? ` / Lv +${levelDelta}` : ''; + return `경험 +${gained} (${unit.exp}/100)${levelText}`; + } + + private equipmentResultText(unit: UnitData) { + const initial = initialFirstBattleUnits.find((candidate) => candidate.id === unit.id); + return equipmentSlots + .map((slot) => { + const state = unit.equipment[slot]; + const item = getItem(state.itemId); + const initialState = initial?.equipment[slot]; + const gained = initialState ? Math.max(0, this.equipmentProgressScore(state.level, state.exp) - this.equipmentProgressScore(initialState.level, initialState.exp)) : 0; + const levelDelta = initialState ? state.level - initialState.level : 0; + const levelText = levelDelta > 0 ? `(+${levelDelta})` : ''; + return `${equipmentSlotLabels[slot]} ${item.name} Lv${state.level}${levelText} +${gained}`; + }) + .join(' '); + } + + private characterProgressScore(level: number, exp: number) { + return (level - 1) * 100 + exp; + } + + private equipmentProgressScore(level: number, exp: number) { + let score = exp; + for (let currentLevel = 1; currentLevel < level; currentLevel += 1) { + score += equipmentExpToNext(currentLevel); + } + return score; + } + private renderResultBondRow(bond: BondState, x: number, y: number, width: number, depth: number) { const bg = this.trackResultObject(this.add.rectangle(x, y, width, 58, 0x101820, 0.88)); bg.setOrigin(0); @@ -1858,7 +1881,7 @@ export class BattleScene extends Phaser.Scene { })); title.setDepth(depth + 1); - const value = this.trackResultObject(this.add.text(x + 12, y + 31, `공명 ${bond.level} 경험 ${bond.exp}/100 전투 +${bond.battleExp}`, { + const value = this.trackResultObject(this.add.text(x + 12, y + 31, this.bondResultText(bond), { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '13px', color: bond.battleExp > 0 ? '#d8b15f' : '#9fb0bf' @@ -1866,6 +1889,14 @@ export class BattleScene extends Phaser.Scene { value.setDepth(depth + 1); } + private bondResultText(bond: BondState) { + const initial = initialFirstBattleBonds.find((candidate) => candidate.id === bond.id); + const gained = initial ? Math.max(0, this.characterProgressScore(bond.level, bond.exp) - this.characterProgressScore(initial.level, initial.exp)) : bond.battleExp; + const levelDelta = initial ? bond.level - initial.level : 0; + const levelText = levelDelta > 0 ? ` / Lv +${levelDelta}` : ''; + return `공명 ${bond.level} 경험 +${gained}${levelText} 전투 +${bond.battleExp}`; + } + private addResultButton(label: string, x: number, y: number, width: number, action: () => void, depth: number) { const bg = this.trackResultObject(this.add.rectangle(x, y, width, 34, 0x1a2630, 0.96)); bg.setDepth(depth); diff --git a/src/game/scenes/StoryScene.ts b/src/game/scenes/StoryScene.ts index 8aa92d8..1facd44 100644 --- a/src/game/scenes/StoryScene.ts +++ b/src/game/scenes/StoryScene.ts @@ -11,8 +11,15 @@ const portraitKeys: Record = { type AlphaObject = Phaser.GameObjects.Image | Phaser.GameObjects.Rectangle | Phaser.GameObjects.Text; +type StorySceneData = { + pages?: StoryPage[]; + nextScene?: string; +}; + export class StoryScene extends Phaser.Scene { private pageIndex = 0; + private pages: StoryPage[] = prologuePages; + private nextScene = 'BattleScene'; private transitioning = false; private background?: Phaser.GameObjects.Image; private chapterText?: Phaser.GameObjects.Text; @@ -27,10 +34,17 @@ export class StoryScene extends Phaser.Scene { super('StoryScene'); } + init(data?: StorySceneData) { + this.pages = data?.pages?.length ? data.pages : prologuePages; + this.nextScene = data?.nextScene ?? 'BattleScene'; + this.pageIndex = 0; + this.transitioning = false; + } + create() { const { width, height } = this.scale; this.add.rectangle(0, 0, width, height, 0x0b0d12).setOrigin(0); - this.background = this.add.image(width / 2, height / 2, prologuePages[0].background); + this.background = this.add.image(width / 2, height / 2, this.pages[0].background); this.drawSceneShade(width, height); this.drawDialogPanel(width, height); this.showPage(0, true); @@ -103,7 +117,7 @@ export class StoryScene extends Phaser.Scene { } private showPage(index: number, immediate = false) { - const page = prologuePages[index]; + const page = this.pages[index]; if (immediate) { this.applyPage(page); @@ -171,7 +185,7 @@ export class StoryScene extends Phaser.Scene { this.nameText?.setText(page.speaker ?? '나레이션'); this.bodyText?.setText(page.text); - this.progressText?.setText(`${this.pageIndex + 1} / ${prologuePages.length} SPACE / ENTER`); + this.progressText?.setText(`${this.pageIndex + 1} / ${this.pages.length} SPACE / ENTER`); } private applyBackground(textureKey: string) { @@ -208,8 +222,8 @@ export class StoryScene extends Phaser.Scene { return; } - if (this.pageIndex >= prologuePages.length - 1) { - this.scene.start('BattleScene'); + if (this.pageIndex >= this.pages.length - 1) { + this.scene.start(this.nextScene); return; }