Polish title screen presentation
This commit is contained in:
@@ -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 삼국지 스타일을 활용하되, 실제 저작권 원본 이미지나 로고를 복제하지 않고 프로젝트 고유 자산으로 제작한다.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
BIN
src/assets/images/taoyuan-oath-title.png
Normal file
BIN
src/assets/images/taoyuan-oath-title.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.2 MiB |
120
src/game/audio/SoundDirector.ts
Normal file
120
src/game/audio/SoundDirector.ts
Normal 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();
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
4
src/vite-env.d.ts
vendored
@@ -1 +1,5 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly DEV: boolean;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user