diff --git a/docs/roadmap.md b/docs/roadmap.md index f41a863..d7412f7 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -14,7 +14,7 @@ Build a small complete tactical RPG loop: - Vite, TypeScript, and Phaser project foundation - Cinematic title scene with an original Taoyuan Oath background, subtle motion, menu UI, and first-input audio -- Prologue dialogue scene backed by scenario data +- Cinematic prologue pages with high-resolution story backgrounds and scenario data - First battle scene with a 12x12 map, terrain colors, unit placement, and movement range preview - Flow verification script from title to first battle diff --git a/scripts/verify-flow.mjs b/scripts/verify-flow.mjs index e53f723..f93df1b 100644 --- a/scripts/verify-flow.mjs +++ b/scripts/verify-flow.mjs @@ -13,8 +13,18 @@ await page.waitForTimeout(500); await page.mouse.click(962, 240); await page.waitForTimeout(250); -for (let i = 0; i < 4; i += 1) { +for (let i = 0; i < 20; i += 1) { + const isBattleActive = await page.evaluate(() => { + const activeScenes = window.__HEROS_GAME__?.scene.getScenes(true) ?? []; + return activeScenes.some((scene) => scene.scene.key === 'BattleScene'); + }); + + if (isBattleActive) { + break; + } + await page.keyboard.press('Space'); + await page.waitForTimeout(220); } await page.waitForFunction(() => { diff --git a/src/assets/images/story/01-yellow-turban-chaos.png b/src/assets/images/story/01-yellow-turban-chaos.png new file mode 100644 index 0000000..60a5454 Binary files /dev/null and b/src/assets/images/story/01-yellow-turban-chaos.png differ diff --git a/src/assets/images/story/02-liu-bei-resolve.png b/src/assets/images/story/02-liu-bei-resolve.png new file mode 100644 index 0000000..5a95a0c Binary files /dev/null and b/src/assets/images/story/02-liu-bei-resolve.png differ diff --git a/src/assets/images/story/03-three-heroes-meet.png b/src/assets/images/story/03-three-heroes-meet.png new file mode 100644 index 0000000..86ef2b4 Binary files /dev/null and b/src/assets/images/story/03-three-heroes-meet.png differ diff --git a/src/assets/images/story/04-volunteer-militia.png b/src/assets/images/story/04-volunteer-militia.png new file mode 100644 index 0000000..7108aa4 Binary files /dev/null and b/src/assets/images/story/04-volunteer-militia.png differ diff --git a/src/assets/images/story/05-first-sortie.png b/src/assets/images/story/05-first-sortie.png new file mode 100644 index 0000000..715c426 Binary files /dev/null and b/src/assets/images/story/05-first-sortie.png differ diff --git a/src/game/data/scenario.ts b/src/game/data/scenario.ts index 059614b..ac39cb3 100644 --- a/src/game/data/scenario.ts +++ b/src/game/data/scenario.ts @@ -1,6 +1,11 @@ -export type StoryLine = { - speaker: string; - portrait: 'lord' | 'strategist' | 'captain'; +export type PortraitKey = 'liuBei' | 'guanYu' | 'zhangFei'; + +export type StoryPage = { + id: string; + chapter: string; + background: string; + speaker?: string; + portrait?: PortraitKey; text: string; }; @@ -25,26 +30,80 @@ export type BattleMap = { terrain: TerrainType[][]; }; -export const prologueLines: StoryLine[] = [ +export const prologuePages: StoryPage[] = [ { - speaker: '유현', - portrait: 'lord', - text: '황건의 깃발이 마을 어귀까지 내려왔다. 오늘 물러서면 내일은 지킬 집도 없다.' + id: 'yellow-turban-chaos', + chapter: '난세의 시작', + background: 'story-chaos', + text: '중평 원년, 황건의 깃발이 각지에서 일어났다. 관군은 흩어지고, 마을마다 피난 행렬이 이어졌다.' }, { - speaker: '서윤', - portrait: 'strategist', - text: '적은 많지만 대열은 흐트러져 있습니다. 숲길을 잡으면 첫 싸움은 해볼 만합니다.' + id: 'liu-bei-resolve', + chapter: '탁현의 방', + background: 'story-liu-bei', + speaker: '유비', + portrait: 'liuBei', + text: '한실의 백성이 이토록 고통받는데, 어찌 초가집 그늘에 숨어 있을 수 있겠는가.' }, { - speaker: '장무', - portrait: 'captain', - text: '명만 내려주십시오. 창병 둘과 기병 하나라면 길목은 막아낼 수 있습니다.' + id: 'zhang-fei-joins', + chapter: '뜻이 모이다', + background: 'story-three-heroes', + speaker: '장비', + portrait: 'zhangFei', + text: '좋소! 나라를 바로잡고 백성을 구한다면, 내 재산도 이 목숨도 아깝지 않소.' }, { - speaker: '유현', - portrait: 'lord', - text: '좋다. 백성을 뒤로 물리고, 우리는 앞에서 시간을 벌겠다.' + id: 'guan-yu-joins', + chapter: '뜻이 모이다', + background: 'story-three-heroes', + speaker: '관우', + portrait: 'guanYu', + text: '의로운 뜻을 품은 이와 길을 함께한다면, 천 리라도 멀다 하지 않겠습니다.' + }, + { + id: 'oath-narration', + chapter: '도원결의', + background: 'title-taoyuan', + text: '복숭아꽃이 흩날리는 동산에서 세 사람은 하늘과 땅에 고했다.' + }, + { + id: 'oath-liu-bei', + chapter: '도원결의', + background: 'title-taoyuan', + speaker: '유비', + portrait: 'liuBei', + text: '우리는 태어난 날은 달라도, 오늘부터 뜻을 함께한다. 백성을 구하고 한실을 돕는 길에서 물러서지 않겠다.' + }, + { + id: 'oath-brothers', + chapter: '도원결의', + background: 'title-taoyuan', + speaker: '관우', + portrait: 'guanYu', + text: '의를 저버리지 않고, 형제를 저버리지 않겠습니다.' + }, + { + id: 'militia-rises', + chapter: '의용군 모집', + background: 'story-militia', + speaker: '장비', + portrait: 'zhangFei', + text: '창을 들 사람은 앞으로 나오시오! 오늘 모인 이름 없는 무리가 내일의 방패가 될 것이오!' + }, + { + id: 'first-sortie', + chapter: '첫 출진', + background: 'story-sortie', + speaker: '유비', + portrait: 'liuBei', + text: '우리는 아직 작다. 그러나 오늘 한 마을을 지켜낸다면, 그 뜻은 천하로 번져갈 것이다.' + }, + { + id: 'battle-briefing', + chapter: '황건적 토벌', + background: 'story-sortie', + text: '의용군은 마을 어귀로 향했다. 첫 싸움은 곧 시작된다.' } ]; @@ -68,9 +127,9 @@ export const firstBattleMap: BattleMap = { }; export const firstBattleUnits: UnitData[] = [ - { id: 'you-hyun', name: '유현', faction: 'ally', className: '의병장', hp: 32, maxHp: 32, attack: 9, move: 4, x: 1, y: 9 }, - { id: 'jang-mu', name: '장무', faction: 'ally', className: '창병', hp: 28, maxHp: 28, attack: 8, move: 3, x: 2, y: 10 }, - { id: 'seo-yun', name: '서윤', faction: 'ally', className: '책사', hp: 24, maxHp: 24, attack: 6, move: 3, x: 1, y: 10 }, + { id: 'liu-bei', name: '유비', faction: 'ally', className: '의병장', hp: 32, maxHp: 32, attack: 9, move: 4, x: 1, y: 9 }, + { id: 'guan-yu', name: '관우', faction: 'ally', className: '보병', hp: 30, maxHp: 30, attack: 10, move: 3, x: 2, y: 10 }, + { id: 'zhang-fei', name: '장비', faction: 'ally', className: '창병', hp: 30, maxHp: 30, attack: 10, move: 3, x: 1, y: 10 }, { id: 'rebel-a', name: '황건병', faction: 'enemy', className: '도적', hp: 20, maxHp: 20, attack: 6, move: 3, x: 8, y: 2 }, { id: 'rebel-b', name: '황건병', faction: 'enemy', className: '도적', hp: 20, maxHp: 20, attack: 6, move: 3, x: 9, y: 3 }, { id: 'rebel-c', name: '황건궁병', faction: 'enemy', className: '궁병', hp: 18, maxHp: 18, attack: 7, move: 3, x: 10, y: 2 }, diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index 163ac11..cb8cb48 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -28,7 +28,7 @@ export class BattleScene extends Phaser.Scene { this.drawMap(); this.drawUnits(); this.drawSidePanel(width, height); - this.setInfo('아군 차례입니다. 유현을 선택해 이동 범위를 확인하세요.'); + this.setInfo('아군 차례입니다. 유비를 선택해 이동 범위를 확인하세요.'); } private drawMap() { @@ -84,7 +84,7 @@ export class BattleScene extends Phaser.Scene { fontSize: '18px', color: '#9aa3ad' }); - this.add.text(panelX + 30, height - 148, '패배 조건: 유현 퇴각', { + this.add.text(panelX + 30, height - 148, '패배 조건: 유비 퇴각', { fontSize: '18px', color: '#9aa3ad' }); diff --git a/src/game/scenes/BootScene.ts b/src/game/scenes/BootScene.ts index 019d4db..0f0fa98 100644 --- a/src/game/scenes/BootScene.ts +++ b/src/game/scenes/BootScene.ts @@ -1,5 +1,10 @@ import Phaser from 'phaser'; import titleBackgroundUrl from '../../assets/images/taoyuan-oath-title.png'; +import storyChaosUrl from '../../assets/images/story/01-yellow-turban-chaos.png'; +import storyLiuBeiUrl from '../../assets/images/story/02-liu-bei-resolve.png'; +import storyThreeHeroesUrl from '../../assets/images/story/03-three-heroes-meet.png'; +import storyMilitiaUrl from '../../assets/images/story/04-volunteer-militia.png'; +import storySortieUrl from '../../assets/images/story/05-first-sortie.png'; import { palette } from '../ui/palette'; export class BootScene extends Phaser.Scene { @@ -9,6 +14,11 @@ export class BootScene extends Phaser.Scene { preload() { this.load.image('title-taoyuan', titleBackgroundUrl); + this.load.image('story-chaos', storyChaosUrl); + this.load.image('story-liu-bei', storyLiuBeiUrl); + this.load.image('story-three-heroes', storyThreeHeroesUrl); + this.load.image('story-militia', storyMilitiaUrl); + this.load.image('story-sortie', storySortieUrl); } create() { @@ -17,9 +27,9 @@ export class BootScene extends Phaser.Scene { } private createGeneratedTextures() { - this.createPortrait('portrait-lord', 0x445f92, 0xd8b15f); - this.createPortrait('portrait-strategist', 0x5b6e55, 0xe8dfca); - this.createPortrait('portrait-captain', 0x8a4f3d, 0xd8b15f); + this.createPortrait('portrait-liu-bei', 0x445f92, 0xd8b15f); + this.createPortrait('portrait-guan-yu', 0x1f5f43, 0xd8b15f); + this.createPortrait('portrait-zhang-fei', 0x8a4f3d, 0xe8dfca); this.createPetalTexture(); this.createUnitTexture('unit-ally', palette.blue, 0xe7edf7); this.createUnitTexture('unit-enemy', palette.red, 0xffebe7); diff --git a/src/game/scenes/StoryScene.ts b/src/game/scenes/StoryScene.ts index a4eb556..678875f 100644 --- a/src/game/scenes/StoryScene.ts +++ b/src/game/scenes/StoryScene.ts @@ -1,18 +1,14 @@ import Phaser from 'phaser'; -import { prologueLines, type StoryLine } from '../data/scenario'; +import { prologuePages, type StoryPage } from '../data/scenario'; import { palette } from '../ui/palette'; -const portraitKeys: Record = { - lord: 'portrait-lord', - strategist: 'portrait-strategist', - captain: 'portrait-captain' -}; - export class StoryScene extends Phaser.Scene { - private lineIndex = 0; - private portrait?: Phaser.GameObjects.Image; + private pageIndex = 0; + private background?: Phaser.GameObjects.Image; + private chapterText?: Phaser.GameObjects.Text; private nameText?: Phaser.GameObjects.Text; private bodyText?: Phaser.GameObjects.Text; + private progressText?: Phaser.GameObjects.Text; constructor() { super('StoryScene'); @@ -20,66 +16,137 @@ export class StoryScene extends Phaser.Scene { create() { const { width, height } = this.scale; - this.add.rectangle(0, 0, width, height, 0x151922).setOrigin(0); - this.drawCampBackground(width, height); + this.add.rectangle(0, 0, width, height, 0x0b0d12).setOrigin(0); + this.background = this.add.image(width / 2, height / 2, prologuePages[0].background); + this.drawSceneShade(width, height); this.drawDialogPanel(width, height); - this.showLine(0); + this.showPage(0, true); this.input.on('pointerdown', () => this.advance()); this.input.keyboard?.on('keydown-SPACE', () => this.advance()); this.input.keyboard?.on('keydown-ENTER', () => this.advance()); } - private drawCampBackground(width: number, height: number) { - const graphics = this.add.graphics(); - graphics.fillStyle(0x26303a, 1); - graphics.fillRect(0, height * 0.54, width, height * 0.46); - graphics.fillStyle(0x5f6d5a, 1); - graphics.fillTriangle(0, height * 0.54, 240, 240, 470, height * 0.54); - graphics.fillStyle(0x384858, 1); - graphics.fillTriangle(300, height * 0.54, 570, 160, 870, height * 0.54); - graphics.fillStyle(0x8a4f3d, 1); - graphics.fillRect(935, 264, 98, 200); - graphics.fillStyle(0xd8b15f, 1); - graphics.fillTriangle(1033, 264, 1180, 304, 1033, 344); + private drawSceneShade(width: number, height: number) { + const shade = this.add.graphics(); + shade.fillStyle(0x040608, 0.22); + shade.fillRect(0, 0, width, height); + shade.fillStyle(0x040608, 0.5); + shade.fillRect(0, height - 270, width, 270); + shade.fillStyle(0x040608, 0.26); + shade.fillRect(0, 0, width, 96); } private drawDialogPanel(width: number, height: number) { - const panelY = height - 214; - this.add.rectangle(64, panelY, width - 128, 158, palette.panel, 0.95).setOrigin(0); - this.add.rectangle(64, panelY, width - 128, 4, palette.gold, 0.9).setOrigin(0); - this.portrait = this.add.image(150, panelY + 79, 'portrait-lord'); - this.nameText = this.add.text(238, panelY + 28, '', { - fontSize: '24px', + const panelY = height - 238; + const panelX = 72; + const panelW = width - 144; + const panelH = 172; + + this.add.rectangle(panelX, panelY, panelW, panelH, palette.panelDark, 0.86).setOrigin(0); + this.add.rectangle(panelX, panelY, panelW, 3, palette.gold, 0.9).setOrigin(0); + this.add.rectangle(panelX, panelY + panelH, panelW, 1, palette.gold, 0.35).setOrigin(0); + + this.chapterText = this.add.text(panelX, panelY - 42, '', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", serif', + fontSize: '22px', color: '#d8b15f', - fontStyle: '700' + fontStyle: '700', + padding: { top: 4, bottom: 4 }, + shadow: { offsetX: 0, offsetY: 2, color: '#05070a', blur: 6, fill: true } }); - this.bodyText = this.add.text(238, panelY + 68, '', { + + this.nameText = this.add.text(panelX + 24, panelY + 28, '', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", serif', fontSize: '24px', - color: '#e8dfca', - wordWrap: { width: width - 370, useAdvancedWrap: true }, - lineSpacing: 8 + color: '#f1e3c2', + fontStyle: '700', + padding: { top: 4, bottom: 4 } }); - this.add.text(width - 190, panelY + 122, 'SPACE / ENTER', { - fontSize: '16px', + + this.bodyText = this.add.text(panelX + 24, panelY + 78, '', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", serif', + fontSize: '25px', + color: '#e8dfca', + wordWrap: { width: panelW - 56, useAdvancedWrap: true }, + lineSpacing: 10, + padding: { top: 4, bottom: 6 }, + shadow: { offsetX: 0, offsetY: 2, color: '#05070a', blur: 5, fill: true } + }); + + this.progressText = this.add.text(width - 220, panelY + panelH + 20, '', { + fontFamily: '"Segoe UI", sans-serif', + fontSize: '15px', color: '#9aa3ad' }); } - private showLine(index: number) { - const line = prologueLines[index]; - this.portrait?.setTexture(portraitKeys[line.portrait]); - this.nameText?.setText(line.speaker); - this.bodyText?.setText(line.text); + private showPage(index: number, immediate = false) { + const page = prologuePages[index]; + + if (immediate) { + this.applyPage(page); + return; + } + + this.cameras.main.fadeOut(120, 0, 0, 0); + this.time.delayedCall(130, () => { + this.applyPage(page); + this.cameras.main.fadeIn(180, 0, 0, 0); + }); + } + + private applyPage(page: StoryPage) { + this.applyBackground(page.background); + this.chapterText?.setText(page.chapter); + this.nameText?.setPosition(96, this.scale.height - 210); + this.bodyText?.setPosition(96, this.scale.height - 160); + this.bodyText?.setWordWrapWidth(this.scale.width - 200, true); + this.nameText?.setText(page.speaker ?? '나레이션'); + this.bodyText?.setText(page.text); + this.progressText?.setText(`${this.pageIndex + 1} / ${prologuePages.length} SPACE / ENTER`); + } + + private applyBackground(textureKey: string) { + if (!this.background) { + return; + } + + const { width, height } = this.scale; + const source = this.textures.get(textureKey).getSourceImage(); + const imageWidth = source.width; + const imageHeight = source.height; + const coverScale = Math.max(width / imageWidth, height / imageHeight); + + this.tweens.killTweensOf(this.background); + this.background.setTexture(textureKey); + this.background.setAlpha(1); + this.background.setScale(coverScale * 1.07); + this.background.setPosition(width / 2 - 16, height / 2); + + this.tweens.add({ + targets: this.background, + x: width / 2 + 16, + y: height / 2 - 8, + scale: coverScale * 1.11, + duration: 15000, + ease: 'Sine.easeInOut', + yoyo: true, + repeat: -1 + }); } private advance() { - if (this.lineIndex >= prologueLines.length - 1) { + if (this.cameras.main.fadeEffect?.isRunning) { + return; + } + + if (this.pageIndex >= prologuePages.length - 1) { this.scene.start('BattleScene'); return; } - this.lineIndex += 1; - this.showLine(this.lineIndex); + this.pageIndex += 1; + this.showPage(this.pageIndex); } }