diff --git a/AGENTS.md b/AGENTS.md index b304817..dfdc0ed 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,3 +4,4 @@ - Use Gitea-related values from `C:\Users\MD\source\repos\nas_connection\.env`, including `GITEA_BASE_URL`, `GITEA_USERNAME`, `GITEA_EMAIL`, `GITEA_TOKEN`, and `GITEA_DEFAULT_OWNER`. - Do not copy secrets from that `.env` file into tracked files, logs, or responses. - For this project, whenever source changes are made, commit the changes and push them to the Gitea remote before finishing the task. +- 이미지는 KOEI 삼국지 스타일을 활용하되, 실제 저작권 원본 이미지나 로고를 복제하지 않고 프로젝트 고유 자산으로 제작한다. diff --git a/docs/roadmap.md b/docs/roadmap.md index 105575e..dc0e04d 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -13,7 +13,7 @@ Build a small complete tactical RPG loop: ## Current Slice - Vite, TypeScript, and Phaser project foundation -- Title scene with a new game entry point +- Cinematic title scene with an original Taoyuan Oath background, subtle motion, menu UI, and first-input audio - Prologue dialogue scene backed by 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 6302019..e53f723 100644 --- a/scripts/verify-flow.mjs +++ b/scripts/verify-flow.mjs @@ -10,7 +10,7 @@ await page.waitForSelector('canvas'); await page.waitForFunction(() => window.__HEROS_GAME__ !== undefined); await page.waitForTimeout(500); -await page.mouse.click(170, 320); +await page.mouse.click(962, 240); await page.waitForTimeout(250); for (let i = 0; i < 4; i += 1) { diff --git a/src/assets/images/taoyuan-oath-title.png b/src/assets/images/taoyuan-oath-title.png new file mode 100644 index 0000000..9030280 Binary files /dev/null and b/src/assets/images/taoyuan-oath-title.png differ diff --git a/src/game/audio/SoundDirector.ts b/src/game/audio/SoundDirector.ts new file mode 100644 index 0000000..5666047 --- /dev/null +++ b/src/game/audio/SoundDirector.ts @@ -0,0 +1,120 @@ +class SoundDirector { + private context?: AudioContext; + private master?: GainNode; + private musicGain?: GainNode; + private started = false; + private sequenceTimer?: number; + + start() { + if (this.started) { + return; + } + + const AudioContextCtor = window.AudioContext ?? window.webkitAudioContext; + if (!AudioContextCtor) { + return; + } + + this.context = new AudioContextCtor(); + this.master = this.context.createGain(); + this.master.gain.value = 0.34; + this.master.connect(this.context.destination); + + this.musicGain = this.context.createGain(); + this.musicGain.gain.value = 0.0; + this.musicGain.connect(this.master); + this.started = true; + + this.createAmbientBed(); + this.schedulePlucks(); + this.musicGain.gain.linearRampToValueAtTime(0.72, this.context.currentTime + 2.4); + } + + resume() { + void this.context?.resume(); + } + + playHover() { + this.playTone(660, 0.035, 0.04, 'sine'); + } + + playSelect() { + this.playTone(330, 0.08, 0.12, 'triangle'); + window.setTimeout(() => this.playTone(495, 0.1, 0.08, 'triangle'), 65); + } + + private createAmbientBed() { + if (!this.context || !this.musicGain) { + return; + } + + const base = this.context.createOscillator(); + const fifth = this.context.createOscillator(); + const filter = this.context.createBiquadFilter(); + const bedGain = this.context.createGain(); + + base.type = 'sine'; + fifth.type = 'sine'; + base.frequency.value = 110; + fifth.frequency.value = 165; + filter.type = 'lowpass'; + filter.frequency.value = 520; + bedGain.gain.value = 0.08; + + base.connect(filter); + fifth.connect(filter); + filter.connect(bedGain); + bedGain.connect(this.musicGain); + base.start(); + fifth.start(); + } + + private schedulePlucks() { + if (!this.context) { + return; + } + + const notes = [220, 247, 294, 330, 392, 330, 294, 247]; + let index = 0; + this.sequenceTimer = window.setInterval(() => { + this.playTone(notes[index % notes.length], 0.18, 0.09, 'triangle', true); + index += 1; + }, 1800); + } + + private playTone( + frequency: number, + duration: number, + gainValue: number, + type: OscillatorType, + routeToMusic = false + ) { + if (!this.context) { + return; + } + + const oscillator = this.context.createOscillator(); + const gain = this.context.createGain(); + const now = this.context.currentTime; + const destination = routeToMusic && this.musicGain ? this.musicGain : this.master; + + oscillator.type = type; + oscillator.frequency.value = frequency; + gain.gain.setValueAtTime(0, now); + gain.gain.linearRampToValueAtTime(gainValue, now + 0.012); + gain.gain.exponentialRampToValueAtTime(0.0001, now + duration); + + oscillator.connect(gain); + gain.connect(destination ?? this.context.destination); + oscillator.start(now); + oscillator.stop(now + duration + 0.04); + } +} + +declare global { + interface Window { + webkitAudioContext?: typeof AudioContext; + } +} + +export const soundDirector = new SoundDirector(); diff --git a/src/game/scenes/BootScene.ts b/src/game/scenes/BootScene.ts index 67633b7..019d4db 100644 --- a/src/game/scenes/BootScene.ts +++ b/src/game/scenes/BootScene.ts @@ -1,4 +1,5 @@ import Phaser from 'phaser'; +import titleBackgroundUrl from '../../assets/images/taoyuan-oath-title.png'; import { palette } from '../ui/palette'; export class BootScene extends Phaser.Scene { @@ -6,6 +7,10 @@ export class BootScene extends Phaser.Scene { super('BootScene'); } + preload() { + this.load.image('title-taoyuan', titleBackgroundUrl); + } + create() { this.createGeneratedTextures(); this.scene.start('TitleScene'); @@ -15,7 +20,7 @@ export class BootScene extends Phaser.Scene { this.createPortrait('portrait-lord', 0x445f92, 0xd8b15f); this.createPortrait('portrait-strategist', 0x5b6e55, 0xe8dfca); this.createPortrait('portrait-captain', 0x8a4f3d, 0xd8b15f); - this.createBannerTexture(); + this.createPetalTexture(); this.createUnitTexture('unit-ally', palette.blue, 0xe7edf7); this.createUnitTexture('unit-enemy', palette.red, 0xffebe7); } @@ -35,19 +40,13 @@ export class BootScene extends Phaser.Scene { graphics.destroy(); } - private createBannerTexture() { + private createPetalTexture() { const graphics = this.make.graphics({ x: 0, y: 0 }); - graphics.fillStyle(0x1b2630, 1); - graphics.fillRect(0, 0, 360, 180); - graphics.fillStyle(0xb64a45, 1); - graphics.fillRect(44, 28, 104, 132); - graphics.fillStyle(0xd8b15f, 1); - graphics.fillTriangle(148, 28, 310, 72, 148, 116); - graphics.fillStyle(0xe8dfca, 1); - graphics.fillCircle(92, 82, 25); - graphics.lineStyle(6, 0x0b0d12, 0.5); - graphics.strokeRect(0, 0, 360, 180); - graphics.generateTexture('title-banner', 360, 180); + graphics.fillStyle(0xf0a6a4, 1); + graphics.fillEllipse(12, 10, 20, 9); + graphics.fillStyle(0xf7c7bd, 0.75); + graphics.fillEllipse(15, 8, 10, 4); + graphics.generateTexture('petal', 24, 18); graphics.destroy(); } diff --git a/src/game/scenes/TitleScene.ts b/src/game/scenes/TitleScene.ts index 2d14665..6dcd6a9 100644 --- a/src/game/scenes/TitleScene.ts +++ b/src/game/scenes/TitleScene.ts @@ -1,57 +1,228 @@ import Phaser from 'phaser'; +import { soundDirector } from '../audio/SoundDirector'; import { palette } from '../ui/palette'; export class TitleScene extends Phaser.Scene { + private focusedButton?: Phaser.GameObjects.Text; + constructor() { super('TitleScene'); } create() { const { width, height } = this.scale; - this.add.rectangle(0, 0, width, height, 0x10131a).setOrigin(0); - this.add.rectangle(0, height * 0.58, width, height * 0.42, 0x20252c, 0.96).setOrigin(0); - this.add.image(width * 0.68, height * 0.33, 'title-banner').setScale(1.1); + this.drawBackground(width, height); + this.drawAtmosphere(width, height); + this.drawTitleMark(width, height); + this.drawMenu(width, height); - this.add.text(96, 108, 'Heros Web', { - fontSize: '62px', - color: '#e8dfca', - fontStyle: '700' - }); - this.add.text(100, 182, '삼국지풍 웹 전술 RPG', { - fontSize: '24px', - color: '#d8b15f' - }); - - const start = this.add.text(104, 296, '새 게임', { - fontSize: '28px', - color: '#10131a', - backgroundColor: '#d8b15f', - padding: { left: 28, right: 28, top: 14, bottom: 14 } - }); - start.setInteractive({ useHandCursor: true }); - start.on('pointerdown', () => this.scene.start('StoryScene')); - - this.add.text(104, 382, '이어하기', { - fontSize: '24px', - color: '#6f7782' - }); - this.add.text(104, 430, '설정', { - fontSize: '24px', - color: '#6f7782' - }); - - this.add.text(96, height - 112, '첫 목표: 프롤로그를 읽고 첫 전투에 진입합니다.', { - fontSize: '20px', - color: '#9aa3ad' - }); - - this.input.keyboard?.once('keydown-ENTER', () => this.scene.start('StoryScene')); - this.addLine(0, height * 0.58, width); + this.input.once('pointerdown', () => soundDirector.start()); + this.input.keyboard?.once('keydown', () => soundDirector.start()); + this.input.keyboard?.once('keydown-ENTER', () => this.startGame()); } - private addLine(x: number, y: number, width: number) { - const line = this.add.graphics(); - line.lineStyle(2, palette.gold, 0.35); - line.lineBetween(x, y, x + width, y); + private drawBackground(width: number, height: number) { + const background = this.add.image(width / 2, height / 2, 'title-taoyuan'); + const texture = this.textures.get('title-taoyuan').getSourceImage(); + const coverScale = Math.max(width / texture.width, height / texture.height); + + background.setScale(coverScale * 1.08); + background.setPosition(width / 2 - 18, height / 2 + 4); + this.tweens.add({ + targets: background, + x: width / 2 + 18, + y: height / 2 - 6, + scale: coverScale * 1.12, + duration: 18000, + ease: 'Sine.easeInOut', + yoyo: true, + repeat: -1 + }); + + this.add.rectangle(0, 0, width, height, 0x080a0e, 0.2).setOrigin(0); + this.add.image(0, 0, this.createShadeTexture(width, height)).setOrigin(0); + } + + private createShadeTexture(width: number, height: number) { + const key = 'title-shade'; + if (this.textures.exists(key)) { + this.textures.remove(key); + } + + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const context = canvas.getContext('2d'); + + if (!context) { + return key; + } + + const rightShade = context.createLinearGradient(width * 0.42, 0, width, 0); + rightShade.addColorStop(0, 'rgba(8, 10, 14, 0)'); + rightShade.addColorStop(0.42, 'rgba(8, 10, 14, 0.2)'); + rightShade.addColorStop(1, 'rgba(8, 10, 14, 0.66)'); + context.fillStyle = rightShade; + context.fillRect(0, 0, width, height); + + const bottomShade = context.createLinearGradient(0, height * 0.55, 0, height); + bottomShade.addColorStop(0, 'rgba(8, 10, 14, 0)'); + bottomShade.addColorStop(1, 'rgba(8, 10, 14, 0.56)'); + context.fillStyle = bottomShade; + context.fillRect(0, 0, width, height); + + this.textures.addCanvas(key, canvas); + return key; + } + + private drawAtmosphere(width: number, height: number) { + const mist = this.add.ellipse(width / 2, height * 0.78, width * 1.25, 190, 0xd8b15f, 0.04); + this.tweens.add({ + targets: mist, + alpha: 0.11, + x: width / 2 + 22, + duration: 8000, + ease: 'Sine.easeInOut', + yoyo: true, + repeat: -1 + }); + + for (let index = 0; index < 34; index += 1) { + const petal = this.add.image( + Phaser.Math.Between(0, width), + Phaser.Math.Between(-80, height), + 'petal' + ); + const scale = Phaser.Math.FloatBetween(0.35, 0.9); + petal.setScale(scale); + petal.setAlpha(Phaser.Math.FloatBetween(0.22, 0.62)); + petal.setRotation(Phaser.Math.FloatBetween(-1.2, 1.2)); + + this.tweens.add({ + targets: petal, + x: petal.x + Phaser.Math.Between(80, 220), + y: height + Phaser.Math.Between(40, 180), + rotation: petal.rotation + Phaser.Math.FloatBetween(2.4, 5.6), + duration: Phaser.Math.Between(12000, 24000), + delay: Phaser.Math.Between(0, 6000), + ease: 'Sine.easeInOut', + repeat: -1, + onRepeat: () => { + petal.setPosition(Phaser.Math.Between(-120, width), Phaser.Math.Between(-180, -40)); + } + }); + } + } + + private drawTitleMark(width: number, height: number) { + const compact = width < 720; + const titleX = compact ? width / 2 : 92; + const title = this.add.text(titleX, height - (compact ? 168 : 172), '桃園結義', { + fontSize: compact ? '48px' : '56px', + color: '#f1e3c2', + fontStyle: '700', + shadow: { offsetX: 0, offsetY: 4, color: '#080a0e', blur: 10, fill: true } + }); + title.setOrigin(compact ? 0.5 : 0, 0); + + const subtitle = this.add.text(compact ? width / 2 : 96, height - 98, 'Heros Web', { + fontSize: '24px', + color: '#d8b15f', + fontStyle: '600', + shadow: { offsetX: 0, offsetY: 2, color: '#080a0e', blur: 6, fill: true } + }); + subtitle.setOrigin(compact ? 0.5 : 0, 0); + } + + private drawMenu(width: number, height: number) { + const compact = width < 720; + const menuX = compact ? width / 2 : width - 318; + const menuY = height * (compact ? 0.36 : 0.43); + + const plate = this.add.rectangle(menuX, menuY, 250, 252, 0x111821, 0.66); + plate.setStrokeStyle(1, palette.gold, 0.45); + this.add.rectangle(menuX, menuY - 126, 186, 2, palette.gold, 0.7); + this.add.rectangle(menuX, menuY + 126, 186, 2, palette.gold, 0.36); + + this.createMenuButton(menuX, menuY - 70, '새 게임', true, () => this.startGame()); + this.createMenuButton(menuX, menuY, '이어하기', false, () => undefined); + this.createMenuButton(menuX, menuY + 70, '설정', true, () => { + soundDirector.playSelect(); + this.flashButtonText('설정은 다음 단계에서 열립니다.'); + }); + } + + private createMenuButton( + x: number, + y: number, + label: string, + enabled: boolean, + onSelect: () => void + ) { + const text = this.add.text(x, y, label, { + fontSize: '30px', + color: enabled ? '#f1e3c2' : '#7d8189', + fontStyle: '700', + fixedWidth: 190, + align: 'center', + padding: { top: 10, bottom: 10 } + }); + text.setOrigin(0.5); + text.setShadow(0, 3, '#080a0e', 8, true, true); + + if (!enabled) { + return text; + } + + text.setInteractive({ useHandCursor: true }); + text.on('pointerover', () => { + this.focusButton(text); + soundDirector.playHover(); + }); + text.on('pointerout', () => this.unfocusButton(text)); + text.on('pointerdown', () => { + soundDirector.start(); + soundDirector.resume(); + onSelect(); + }); + + return text; + } + + private focusButton(text: Phaser.GameObjects.Text) { + this.focusedButton = text; + text.setColor('#111821'); + text.setBackgroundColor('#d8b15f'); + } + + private unfocusButton(text: Phaser.GameObjects.Text) { + if (this.focusedButton === text) { + this.focusedButton = undefined; + } + text.setColor('#f1e3c2'); + text.setBackgroundColor('rgba(0,0,0,0)'); + } + + private flashButtonText(message: string) { + const toast = this.add.text(this.scale.width - 318, this.scale.height * 0.43 + 138, message, { + fontSize: '16px', + color: '#d8b15f', + fixedWidth: 250, + align: 'center' + }); + toast.setOrigin(0.5); + this.tweens.add({ + targets: toast, + alpha: 0, + y: toast.y + 12, + duration: 1200, + ease: 'Sine.easeOut', + onComplete: () => toast.destroy() + }); + } + + private startGame() { + soundDirector.playSelect(); + this.scene.start('StoryScene'); } } diff --git a/src/main.ts b/src/main.ts index ccef4b6..4e59c4c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -18,7 +18,7 @@ const config: Phaser.Types.Core.GameConfig = { height: 720, backgroundColor: '#10131a', scale: { - mode: Phaser.Scale.FIT, + mode: Phaser.Scale.RESIZE, autoCenter: Phaser.Scale.CENTER_BOTH }, scene: [BootScene, TitleScene, StoryScene, BattleScene] diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 11f02fe..036cd0f 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -1 +1,5 @@ /// + +interface ImportMetaEnv { + readonly DEV: boolean; +}