Polish title screen presentation

This commit is contained in:
2026-06-21 22:39:35 +09:00
parent 764df7edce
commit a9a72e9231
9 changed files with 353 additions and 58 deletions

View File

@@ -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 삼국지 스타일을 활용하되, 실제 저작권 원본 이미지나 로고를 복제하지 않고 프로젝트 고유 자산으로 제작한다.

View File

@@ -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

View File

@@ -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) {

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

View File

@@ -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();

View File

@@ -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();
}

View File

@@ -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');
}
}

View File

@@ -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]

4
src/vite-env.d.ts vendored
View File

@@ -1 +1,5 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly DEV: boolean;
}