Scaffold tactical RPG web prototype
This commit is contained in:
139
src/game/scenes/BattleScene.ts
Normal file
139
src/game/scenes/BattleScene.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import Phaser from 'phaser';
|
||||
import { firstBattleMap, firstBattleUnits, type TerrainType, type UnitData } from '../data/scenario';
|
||||
import { palette } from '../ui/palette';
|
||||
|
||||
const tileSize = 48;
|
||||
const mapOffset = { x: 64, y: 72 };
|
||||
|
||||
const terrainColor: Record<TerrainType, number> = {
|
||||
plain: 0x6d8056,
|
||||
forest: 0x355f42,
|
||||
hill: 0x82724e,
|
||||
fort: 0x8b6f48
|
||||
};
|
||||
|
||||
export class BattleScene extends Phaser.Scene {
|
||||
private selectedUnit?: UnitData;
|
||||
private infoText?: Phaser.GameObjects.Text;
|
||||
private turnText?: Phaser.GameObjects.Text;
|
||||
private markers: Phaser.GameObjects.Rectangle[] = [];
|
||||
|
||||
constructor() {
|
||||
super('BattleScene');
|
||||
}
|
||||
|
||||
create() {
|
||||
const { width, height } = this.scale;
|
||||
this.add.rectangle(0, 0, width, height, 0x0f1418).setOrigin(0);
|
||||
this.drawMap();
|
||||
this.drawUnits();
|
||||
this.drawSidePanel(width, height);
|
||||
this.setInfo('아군 차례입니다. 유현을 선택해 이동 범위를 확인하세요.');
|
||||
}
|
||||
|
||||
private drawMap() {
|
||||
firstBattleMap.terrain.forEach((row, y) => {
|
||||
row.forEach((terrain, x) => {
|
||||
const tileX = mapOffset.x + x * tileSize;
|
||||
const tileY = mapOffset.y + y * tileSize;
|
||||
const tile = this.add.rectangle(tileX, tileY, tileSize, tileSize, terrainColor[terrain]).setOrigin(0);
|
||||
tile.setStrokeStyle(1, 0x182028, 0.8);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private drawUnits() {
|
||||
firstBattleUnits.forEach((unit) => {
|
||||
const sprite = this.add.image(
|
||||
mapOffset.x + unit.x * tileSize + tileSize / 2,
|
||||
mapOffset.y + unit.y * tileSize + tileSize / 2,
|
||||
unit.faction === 'ally' ? 'unit-ally' : 'unit-enemy'
|
||||
);
|
||||
sprite.setInteractive({ useHandCursor: unit.faction === 'ally' });
|
||||
sprite.on('pointerdown', () => this.selectUnit(unit));
|
||||
|
||||
this.add.text(sprite.x - 22, sprite.y + 22, unit.name, {
|
||||
fontSize: '13px',
|
||||
color: unit.faction === 'ally' ? '#e7edf7' : '#ffebe7',
|
||||
backgroundColor: '#10131a',
|
||||
padding: { left: 4, right: 4, top: 2, bottom: 2 }
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private drawSidePanel(width: number, height: number) {
|
||||
const panelX = mapOffset.x + firstBattleMap.width * tileSize + 36;
|
||||
this.add.rectangle(panelX, 72, width - panelX - 64, height - 128, palette.panel, 0.96).setOrigin(0);
|
||||
this.add.text(panelX + 30, 108, '첫 전투', {
|
||||
fontSize: '32px',
|
||||
color: '#e8dfca',
|
||||
fontStyle: '700'
|
||||
});
|
||||
this.turnText = this.add.text(panelX + 30, 164, '1턴 / 아군', {
|
||||
fontSize: '22px',
|
||||
color: '#d8b15f'
|
||||
});
|
||||
this.infoText = this.add.text(panelX + 30, 224, '', {
|
||||
fontSize: '20px',
|
||||
color: '#e8dfca',
|
||||
wordWrap: { width: width - panelX - 124, useAdvancedWrap: true },
|
||||
lineSpacing: 8
|
||||
});
|
||||
|
||||
this.add.text(panelX + 30, height - 186, '승리 조건: 적 전멸', {
|
||||
fontSize: '18px',
|
||||
color: '#9aa3ad'
|
||||
});
|
||||
this.add.text(panelX + 30, height - 148, '패배 조건: 유현 퇴각', {
|
||||
fontSize: '18px',
|
||||
color: '#9aa3ad'
|
||||
});
|
||||
this.add.text(panelX + 30, height - 110, '다음 단계: 이동/공격/AI 구현', {
|
||||
fontSize: '18px',
|
||||
color: '#9aa3ad'
|
||||
});
|
||||
this.turnText.setText('1턴 / 아군');
|
||||
}
|
||||
|
||||
private selectUnit(unit: UnitData) {
|
||||
if (unit.faction !== 'ally') {
|
||||
this.setInfo(`${unit.name}: ${unit.className}, HP ${unit.hp}/${unit.maxHp}`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.selectedUnit = unit;
|
||||
this.clearMarkers();
|
||||
this.showMoveRange(unit);
|
||||
this.setInfo(`${unit.name} (${unit.className})\nHP ${unit.hp}/${unit.maxHp}, 공격 ${unit.attack}, 이동 ${unit.move}`);
|
||||
}
|
||||
|
||||
private showMoveRange(unit: UnitData) {
|
||||
for (let y = 0; y < firstBattleMap.height; y += 1) {
|
||||
for (let x = 0; x < firstBattleMap.width; x += 1) {
|
||||
const distance = Math.abs(unit.x - x) + Math.abs(unit.y - y);
|
||||
if (distance > 0 && distance <= unit.move) {
|
||||
const marker = this.add.rectangle(
|
||||
mapOffset.x + x * tileSize,
|
||||
mapOffset.y + y * tileSize,
|
||||
tileSize,
|
||||
tileSize,
|
||||
palette.blue,
|
||||
0.24
|
||||
);
|
||||
marker.setOrigin(0);
|
||||
marker.setStrokeStyle(1, palette.blue, 0.6);
|
||||
this.markers.push(marker);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private clearMarkers() {
|
||||
this.markers.forEach((marker) => marker.destroy());
|
||||
this.markers = [];
|
||||
}
|
||||
|
||||
private setInfo(text: string) {
|
||||
this.infoText?.setText(text);
|
||||
}
|
||||
}
|
||||
67
src/game/scenes/BootScene.ts
Normal file
67
src/game/scenes/BootScene.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import Phaser from 'phaser';
|
||||
import { palette } from '../ui/palette';
|
||||
|
||||
export class BootScene extends Phaser.Scene {
|
||||
constructor() {
|
||||
super('BootScene');
|
||||
}
|
||||
|
||||
create() {
|
||||
this.createGeneratedTextures();
|
||||
this.scene.start('TitleScene');
|
||||
}
|
||||
|
||||
private createGeneratedTextures() {
|
||||
this.createPortrait('portrait-lord', 0x445f92, 0xd8b15f);
|
||||
this.createPortrait('portrait-strategist', 0x5b6e55, 0xe8dfca);
|
||||
this.createPortrait('portrait-captain', 0x8a4f3d, 0xd8b15f);
|
||||
this.createBannerTexture();
|
||||
this.createUnitTexture('unit-ally', palette.blue, 0xe7edf7);
|
||||
this.createUnitTexture('unit-enemy', palette.red, 0xffebe7);
|
||||
}
|
||||
|
||||
private createPortrait(key: string, primary: number, accent: number) {
|
||||
const graphics = this.make.graphics({ x: 0, y: 0 });
|
||||
graphics.fillStyle(0x141820, 1);
|
||||
graphics.fillRoundedRect(0, 0, 128, 128, 8);
|
||||
graphics.fillStyle(primary, 1);
|
||||
graphics.fillCircle(64, 46, 26);
|
||||
graphics.fillRoundedRect(31, 71, 66, 42, 12);
|
||||
graphics.fillStyle(accent, 1);
|
||||
graphics.fillTriangle(38, 29, 64, 8, 90, 29);
|
||||
graphics.lineStyle(4, 0x0b0d12, 0.45);
|
||||
graphics.strokeRoundedRect(2, 2, 124, 124, 8);
|
||||
graphics.generateTexture(key, 128, 128);
|
||||
graphics.destroy();
|
||||
}
|
||||
|
||||
private createBannerTexture() {
|
||||
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.destroy();
|
||||
}
|
||||
|
||||
private createUnitTexture(key: string, primary: number, accent: number) {
|
||||
const graphics = this.make.graphics({ x: 0, y: 0 });
|
||||
graphics.fillStyle(0x0c0f14, 0.5);
|
||||
graphics.fillEllipse(32, 49, 44, 17);
|
||||
graphics.fillStyle(primary, 1);
|
||||
graphics.fillRoundedRect(14, 12, 36, 38, 8);
|
||||
graphics.fillStyle(accent, 1);
|
||||
graphics.fillCircle(32, 17, 9);
|
||||
graphics.lineStyle(3, 0x0b0d12, 0.55);
|
||||
graphics.strokeRoundedRect(14, 12, 36, 38, 8);
|
||||
graphics.generateTexture(key, 64, 64);
|
||||
graphics.destroy();
|
||||
}
|
||||
}
|
||||
85
src/game/scenes/StoryScene.ts
Normal file
85
src/game/scenes/StoryScene.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import Phaser from 'phaser';
|
||||
import { prologueLines, type StoryLine } 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 nameText?: Phaser.GameObjects.Text;
|
||||
private bodyText?: Phaser.GameObjects.Text;
|
||||
|
||||
constructor() {
|
||||
super('StoryScene');
|
||||
}
|
||||
|
||||
create() {
|
||||
const { width, height } = this.scale;
|
||||
this.add.rectangle(0, 0, width, height, 0x151922).setOrigin(0);
|
||||
this.drawCampBackground(width, height);
|
||||
this.drawDialogPanel(width, height);
|
||||
this.showLine(0);
|
||||
|
||||
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 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',
|
||||
color: '#d8b15f',
|
||||
fontStyle: '700'
|
||||
});
|
||||
this.bodyText = this.add.text(238, panelY + 68, '', {
|
||||
fontSize: '24px',
|
||||
color: '#e8dfca',
|
||||
wordWrap: { width: width - 370, useAdvancedWrap: true },
|
||||
lineSpacing: 8
|
||||
});
|
||||
this.add.text(width - 190, panelY + 122, 'SPACE / ENTER', {
|
||||
fontSize: '16px',
|
||||
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 advance() {
|
||||
if (this.lineIndex >= prologueLines.length - 1) {
|
||||
this.scene.start('BattleScene');
|
||||
return;
|
||||
}
|
||||
|
||||
this.lineIndex += 1;
|
||||
this.showLine(this.lineIndex);
|
||||
}
|
||||
}
|
||||
57
src/game/scenes/TitleScene.ts
Normal file
57
src/game/scenes/TitleScene.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import Phaser from 'phaser';
|
||||
import { palette } from '../ui/palette';
|
||||
|
||||
export class TitleScene extends Phaser.Scene {
|
||||
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.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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user