Add cinematic prologue story
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
BIN
src/assets/images/story/01-yellow-turban-chaos.png
Normal file
BIN
src/assets/images/story/01-yellow-turban-chaos.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.2 MiB |
BIN
src/assets/images/story/02-liu-bei-resolve.png
Normal file
BIN
src/assets/images/story/02-liu-bei-resolve.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.1 MiB |
BIN
src/assets/images/story/03-three-heroes-meet.png
Normal file
BIN
src/assets/images/story/03-three-heroes-meet.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.2 MiB |
BIN
src/assets/images/story/04-volunteer-militia.png
Normal file
BIN
src/assets/images/story/04-volunteer-militia.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.3 MiB |
BIN
src/assets/images/story/05-first-sortie.png
Normal file
BIN
src/assets/images/story/05-first-sortie.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.4 MiB |
@@ -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 },
|
||||
|
||||
@@ -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'
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<StoryLine['portrait'], string> = {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user