Files
heros_web/src/game/scenes/TitleScene.ts
2026-06-22 00:21:45 +09:00

291 lines
9.0 KiB
TypeScript

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;
private settingsPanel?: Phaser.GameObjects.Container;
constructor() {
super('TitleScene');
}
create() {
const { width, height } = this.scale;
this.drawBackground(width, height);
this.drawAtmosphere(width, height);
this.drawTitleMark(width, height);
this.drawMenu(width, height);
soundDirector.playMusic('title-theme');
const unlockAudio = () => {
soundDirector.start();
soundDirector.resume();
soundDirector.playMusic('title-theme');
};
this.input.once('pointerdown', unlockAudio);
this.input.keyboard?.once('keydown', unlockAudio);
this.input.keyboard?.once('keydown-ENTER', () => this.startGame());
}
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 ? 190 : 204), '삼국지', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", serif',
fontSize: compact ? '50px' : '64px',
color: '#f1e3c2',
fontStyle: '700',
padding: { top: 8, bottom: 8 },
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 - (compact ? 112 : 118), '세 형제의 맹세', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", serif',
fontSize: compact ? '26px' : '34px',
color: '#d8b15f',
fontStyle: '700',
padding: { top: 10, bottom: 8 },
shadow: { offsetX: 0, offsetY: 2, color: '#080a0e', blur: 6, fill: true }
});
subtitle.setOrigin(compact ? 0.5 : 0, 0);
const lineX = compact ? width / 2 - 110 : 98;
const lineY = height - (compact ? 76 : 70);
this.add.rectangle(lineX, lineY, compact ? 220 : 310, 2, palette.gold, 0.68).setOrigin(0, 0.5);
}
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, () => this.openSettingsPanel(menuX, menuY));
}
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 openSettingsPanel(x: number, y: number) {
if (this.settingsPanel) {
this.closeSettingsPanel();
return;
}
soundDirector.playSelect();
const panel = this.add.container(x, y + 238);
const backdrop = this.add.rectangle(0, 0, 286, 188, 0x0e151d, 0.9);
backdrop.setStrokeStyle(1, palette.gold, 0.52);
const title = this.add.text(0, -66, '설정', {
fontSize: '24px',
color: '#f1e3c2',
fontStyle: '700',
fixedWidth: 210,
align: 'center'
});
title.setOrigin(0.5);
const soundText = this.createSettingsButton(0, -10, this.soundLabel());
soundText.on('pointerdown', () => {
soundDirector.setMuted(!soundDirector.isMuted());
soundText.setText(this.soundLabel());
soundDirector.playSelect();
});
const close = this.createSettingsButton(0, 48, '닫기');
close.on('pointerdown', () => {
soundDirector.playSelect();
this.closeSettingsPanel();
});
panel.add([backdrop, title, soundText, close]);
panel.setAlpha(0);
this.settingsPanel = panel;
this.tweens.add({ targets: panel, alpha: 1, y: y + 228, duration: 160, ease: 'Sine.easeOut' });
}
private createSettingsButton(x: number, y: number, label: string) {
const text = this.add.text(x, y, label, {
fontSize: '20px',
color: '#d8b15f',
fixedWidth: 210,
align: 'center',
padding: { top: 8, bottom: 8 }
});
text.setOrigin(0.5);
text.setInteractive({ useHandCursor: true });
text.on('pointerover', () => text.setColor('#f1e3c2'));
text.on('pointerout', () => text.setColor('#d8b15f'));
return text;
}
private soundLabel() {
return `음향: ${soundDirector.isMuted() ? '꺼짐' : '켜짐'}`;
}
private closeSettingsPanel() {
this.settingsPanel?.destroy();
this.settingsPanel = undefined;
}
private startGame() {
soundDirector.start();
soundDirector.resume();
soundDirector.playSelect();
this.scene.start('StoryScene');
}
}