Add unit movement and denser pixel sprites

This commit is contained in:
2026-06-22 01:02:03 +09:00
parent 9dff6b0f47
commit 371bc2a263
3 changed files with 248 additions and 108 deletions

View File

@@ -16,16 +16,15 @@ Build a small complete tactical RPG loop:
- Cinematic title scene with an original Taoyuan Oath background, subtle motion, menu UI, and first-input audio
- Cinematic prologue pages with high-resolution story backgrounds, character portraits, and scenario data
- File-based original orchestral BGM loops for title, prologue, oath, militia, and battle-prep scenes
- First battle scene with a high-resolution battlefield background, large 12x12 tactical grid, pixel-style unit sprites, and movement range preview
- First battle scene with a high-resolution battlefield background, large 12x12 tactical grid, denser pixel-style unit sprites, movement range preview, and click-to-move unit movement
- Flow verification script from title to first battle
## Next Steps
1. Add selectable commands: move, attack, wait
2. Implement grid movement and occupied-tile blocking
3. Implement attack range, damage, defeat, and victory checks
4. Add a simple enemy AI turn
5. Add battle result scene and local save/load
1. Add selectable commands: attack, wait, and end turn
2. Implement attack range, damage, defeat, and victory checks
3. Add a simple enemy AI turn
4. Add battle result scene and local save/load
## Content Direction

View File

@@ -35,12 +35,18 @@ type BattleLayout = {
panelHeight: number;
};
type UnitView = {
sprite: Phaser.GameObjects.Image;
label: Phaser.GameObjects.Text;
};
export class BattleScene extends Phaser.Scene {
private layout!: BattleLayout;
private selectedUnit?: UnitData;
private infoText?: Phaser.GameObjects.Text;
private turnText?: Phaser.GameObjects.Text;
private markers: Phaser.GameObjects.Rectangle[] = [];
private unitViews = new Map<string, UnitView>();
constructor() {
super('BattleScene');
@@ -55,7 +61,7 @@ export class BattleScene extends Phaser.Scene {
this.drawMap();
this.drawUnits();
this.drawSidePanel();
this.setInfo('아군 차례입니다.\n유비를 선택해 이동 범위를 확인하세요.');
this.setInfo('아군 차례입니다.\n유비를 선택한 뒤 파란 칸을 눌러 이동하세요.');
}
private createLayout(width: number, height: number): BattleLayout {
@@ -112,10 +118,14 @@ export class BattleScene extends Phaser.Scene {
firstBattleMap.terrain.forEach((row, y) => {
row.forEach((terrain, x) => {
const tileX = layout.gridX + x * layout.tileSize;
const tileY = layout.gridY + y * layout.tileSize;
const terrainAlpha = terrain === 'plain' ? 0.03 : terrain === 'forest' ? 0.16 : terrain === 'hill' ? 0.13 : 0.18;
const tile = this.add.rectangle(tileX, tileY, layout.tileSize, layout.tileSize, terrainColor[terrain], terrainAlpha);
const tile = this.add.rectangle(
layout.gridX + x * layout.tileSize,
layout.gridY + y * layout.tileSize,
layout.tileSize,
layout.tileSize,
terrainColor[terrain],
this.terrainAlpha(terrain)
);
tile.setOrigin(0);
tile.setStrokeStyle(1, 0x0a1014, 0.5);
tile.setDepth(2);
@@ -130,21 +140,16 @@ export class BattleScene extends Phaser.Scene {
}
private drawUnits() {
const layout = this.layout;
firstBattleUnits.forEach((unit) => {
const unitSize = unit.faction === 'ally' ? layout.tileSize * 0.78 : layout.tileSize * 0.72;
const sprite = this.add.image(
layout.gridX + unit.x * layout.tileSize + layout.tileSize / 2,
layout.gridY + unit.y * layout.tileSize + layout.tileSize / 2,
unitTexture[unit.id] ?? 'unit-rebel'
);
sprite.setDisplaySize(unitSize, unitSize);
const sizeRatio = unit.faction === 'ally' ? 0.9 : 0.84;
const sprite = this.add.image(this.tileCenterX(unit.x), this.tileCenterY(unit.y), unitTexture[unit.id] ?? 'unit-rebel');
sprite.setName(`unit-${unit.id}`);
sprite.setDisplaySize(this.layout.tileSize * sizeRatio, this.layout.tileSize * sizeRatio);
sprite.setDepth(6);
sprite.setInteractive({ useHandCursor: unit.faction === 'ally' });
sprite.on('pointerdown', () => this.selectUnit(unit));
const label = this.add.text(sprite.x, sprite.y + layout.tileSize * 0.35, unit.name, {
const label = this.add.text(sprite.x, sprite.y + this.layout.tileSize * 0.36, unit.name, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '13px',
color: unit.faction === 'ally' ? '#e7edf7' : '#ffebe7',
@@ -153,6 +158,7 @@ export class BattleScene extends Phaser.Scene {
});
label.setOrigin(0.5, 0);
label.setDepth(7);
this.unitViews.set(unit.id, { sprite, label });
});
}
@@ -191,7 +197,7 @@ export class BattleScene extends Phaser.Scene {
fontSize: '18px',
color: '#9aa3ad'
});
this.add.text(panelX + 24, panelY + panelHeight - 86, '다음 단계: 이동 / 공격 / AI', {
this.add.text(panelX + 24, panelY + panelHeight - 86, '다음 단계: 공격 / 대기 / AI', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '18px',
color: '#9aa3ad'
@@ -207,33 +213,110 @@ export class BattleScene extends Phaser.Scene {
this.selectedUnit = unit;
this.clearMarkers();
this.showMoveRange(unit);
this.setInfo(`${unit.name} (${unit.className})\nHP ${unit.hp}/${unit.maxHp}\n공격 ${unit.attack} / 이동 ${unit.move}`);
this.setInfo(`${unit.name} (${unit.className})\nHP ${unit.hp}/${unit.maxHp}\n공격 ${unit.attack} / 이동 ${unit.move}\n이동할 파란 칸을 선택하세요.`);
}
private showMoveRange(unit: UnitData) {
const layout = this.layout;
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(
layout.gridX + x * layout.tileSize,
layout.gridY + y * layout.tileSize,
layout.tileSize,
layout.tileSize,
palette.blue,
0.24
);
marker.setOrigin(0);
marker.setStrokeStyle(1, palette.blue, 0.68);
marker.setDepth(4);
this.markers.push(marker);
if (!this.canMoveTo(unit, x, y)) {
continue;
}
const marker = this.add.rectangle(
this.layout.gridX + x * this.layout.tileSize,
this.layout.gridY + y * this.layout.tileSize,
this.layout.tileSize,
this.layout.tileSize,
palette.blue,
0.24
);
marker.setOrigin(0);
marker.setStrokeStyle(1, palette.blue, 0.68);
marker.setDepth(4);
marker.setInteractive({ useHandCursor: true });
marker.on('pointerover', () => marker.setFillStyle(palette.blue, 0.36));
marker.on('pointerout', () => marker.setFillStyle(palette.blue, 0.24));
marker.on('pointerdown', () => this.moveSelectedUnit(x, y));
this.markers.push(marker);
}
}
}
private moveSelectedUnit(x: number, y: number) {
if (!this.selectedUnit || !this.canMoveTo(this.selectedUnit, x, y)) {
return;
}
const unit = this.selectedUnit;
const view = this.unitViews.get(unit.id);
if (!view) {
return;
}
unit.x = x;
unit.y = y;
this.selectedUnit = undefined;
this.clearMarkers();
soundDirector.playSelect();
const targetX = this.tileCenterX(x);
const targetY = this.tileCenterY(y);
this.tweens.add({
targets: view.sprite,
x: targetX,
y: targetY,
duration: 260,
ease: 'Sine.easeInOut'
});
this.tweens.add({
targets: view.label,
x: targetX,
y: targetY + this.layout.tileSize * 0.36,
duration: 260,
ease: 'Sine.easeInOut'
});
this.setInfo(`${unit.name} 이동 완료\n위치: ${x + 1}, ${y + 1}\n다음 단계에서 공격/대기 명령을 붙일 예정입니다.`);
}
private canMoveTo(unit: UnitData, x: number, y: number) {
const distance = Math.abs(unit.x - x) + Math.abs(unit.y - y);
return (
distance > 0 &&
distance <= unit.move &&
x >= 0 &&
y >= 0 &&
x < firstBattleMap.width &&
y < firstBattleMap.height &&
!this.isOccupied(x, y, unit.id)
);
}
private isOccupied(x: number, y: number, exceptUnitId?: string) {
return firstBattleUnits.some((unit) => unit.id !== exceptUnitId && unit.x === x && unit.y === y);
}
private terrainAlpha(terrain: TerrainType) {
if (terrain === 'forest') {
return 0.16;
}
if (terrain === 'hill') {
return 0.13;
}
if (terrain === 'fort') {
return 0.18;
}
return 0.03;
}
private tileCenterX(x: number) {
return this.layout.gridX + x * this.layout.tileSize + this.layout.tileSize / 2;
}
private tileCenterY(y: number) {
return this.layout.gridY + y * this.layout.tileSize + this.layout.tileSize / 2;
}
private clearMarkers() {
this.markers.forEach((marker) => marker.destroy());
this.markers = [];

View File

@@ -11,6 +11,21 @@ import liuBeiPortraitUrl from '../../assets/images/portraits/liu-bei.png';
import zhangFeiPortraitUrl from '../../assets/images/portraits/zhang-fei.png';
import { palette } from '../ui/palette';
type UnitPixelPalette = {
robe: number;
trim: number;
shade: number;
skin: number;
hair: number;
weapon: number;
accent: number;
beard?: boolean;
spear?: boolean;
halberd?: boolean;
mounted?: boolean;
cape?: boolean;
};
export class BootScene extends Phaser.Scene {
constructor() {
super('BootScene');
@@ -53,6 +68,7 @@ export class BootScene extends Phaser.Scene {
this.createPixelUnitTexture('unit-liu-bei', {
robe: palette.blue,
trim: 0xe7edf7,
shade: 0x1d3552,
skin: 0xf0c79a,
hair: 0x1e1b17,
weapon: 0xd8d0ba,
@@ -61,6 +77,7 @@ export class BootScene extends Phaser.Scene {
this.createPixelUnitTexture('unit-guan-yu', {
robe: 0x2f7d45,
trim: 0xd7e6c5,
shade: 0x1f5131,
skin: 0xd59b6e,
hair: 0x241611,
weapon: 0xc7d2d8,
@@ -71,6 +88,7 @@ export class BootScene extends Phaser.Scene {
this.createPixelUnitTexture('unit-zhang-fei', {
robe: 0x5b2b33,
trim: 0xefc67c,
shade: 0x351820,
skin: 0xb97955,
hair: 0x16110f,
weapon: 0xcfd8d8,
@@ -80,6 +98,7 @@ export class BootScene extends Phaser.Scene {
this.createPixelUnitTexture('unit-rebel', {
robe: 0xc8a032,
trim: 0x6e4f24,
shade: 0x7b6221,
skin: 0xbc8053,
hair: 0x242015,
weapon: 0xbfc5bd,
@@ -89,6 +108,7 @@ export class BootScene extends Phaser.Scene {
this.createPixelUnitTexture('unit-rebel-cavalry', {
robe: 0xd0aa3f,
trim: 0x7a4b2a,
shade: 0x7a6428,
skin: 0xbc8053,
hair: 0x262017,
weapon: 0xbfc5bd,
@@ -98,6 +118,7 @@ export class BootScene extends Phaser.Scene {
this.createPixelUnitTexture('unit-rebel-leader', {
robe: 0x9e7130,
trim: 0xf4d66f,
shade: 0x60421f,
skin: 0xc98b5d,
hair: 0x1e1710,
weapon: 0xd4d7c6,
@@ -107,86 +128,123 @@ export class BootScene extends Phaser.Scene {
});
}
private createPixelUnitTexture(
key: string,
colors: {
robe: number;
trim: number;
skin: number;
hair: number;
weapon: number;
accent: number;
beard?: boolean;
spear?: boolean;
halberd?: boolean;
mounted?: boolean;
cape?: boolean;
}
) {
private createPixelUnitTexture(key: string, colors: UnitPixelPalette) {
const graphics = this.make.graphics({ x: 0, y: 0 });
graphics.fillStyle(0x07090c, 0.42);
graphics.fillRect(6, 27, 20, 3);
this.drawPixelShadow(graphics);
if (colors.mounted) {
graphics.fillStyle(0x5b3824, 1);
graphics.fillRect(7, 18, 18, 6);
graphics.fillRect(5, 21, 5, 3);
graphics.fillRect(22, 17, 4, 4);
graphics.fillStyle(0x2f2118, 1);
graphics.fillRect(9, 24, 3, 5);
graphics.fillRect(21, 23, 3, 5);
this.drawHorse(graphics, colors);
}
if (colors.cape) {
graphics.fillStyle(0x6c2722, 1);
graphics.fillRect(10, 13, 13, 13);
graphics.fillRect(8, 18, 5, 7);
graphics.fillRect(15, 19, 20, 18);
graphics.fillRect(12, 26, 7, 12);
graphics.fillStyle(0x3f1715, 1);
graphics.fillRect(13, 35, 17, 3);
}
graphics.fillStyle(colors.weapon, 1);
if (colors.spear) {
graphics.fillRect(24, 6, 2, 22);
graphics.fillRect(23, 5, 4, 2);
} else if (colors.halberd) {
graphics.fillRect(24, 5, 2, 23);
graphics.fillRect(22, 5, 6, 2);
graphics.fillRect(25, 7, 4, 3);
} else {
graphics.fillRect(23, 12, 2, 14);
graphics.fillRect(22, 11, 5, 2);
}
this.drawWeapon(graphics, colors);
this.drawBody(graphics, colors);
this.drawHead(graphics, colors);
graphics.fillStyle(colors.robe, 1);
graphics.fillRect(11, 13, 10, 12);
graphics.fillRect(9, 18, 14, 8);
graphics.fillStyle(colors.trim, 1);
graphics.fillRect(12, 14, 8, 2);
graphics.fillRect(10, 25, 13, 2);
graphics.fillStyle(colors.skin, 1);
graphics.fillRect(12, 7, 8, 7);
graphics.fillRect(10, 15, 3, 5);
graphics.fillRect(20, 15, 3, 5);
graphics.fillStyle(colors.hair, 1);
graphics.fillRect(11, 6, 10, 3);
graphics.fillRect(10, 8, 2, 5);
graphics.fillRect(20, 8, 2, 5);
if (colors.beard) {
graphics.fillStyle(0x2a1712, 1);
graphics.fillRect(13, 13, 6, 6);
graphics.fillRect(14, 19, 4, 4);
}
graphics.fillStyle(colors.accent, 1);
graphics.fillRect(14, 5, 4, 2);
graphics.fillRect(14, 17, 5, 2);
graphics.fillStyle(0x06080b, 1);
graphics.fillRect(13, 10, 2, 1);
graphics.fillRect(18, 10, 2, 1);
graphics.generateTexture(key, 32, 32);
graphics.generateTexture(key, 48, 48);
graphics.destroy();
this.textures.get(key).setFilter(Phaser.Textures.FilterMode.NEAREST);
}
private drawPixelShadow(graphics: Phaser.GameObjects.Graphics) {
graphics.fillStyle(0x07090c, 0.45);
graphics.fillRect(10, 41, 30, 3);
graphics.fillRect(14, 39, 23, 2);
}
private drawHorse(graphics: Phaser.GameObjects.Graphics, colors: UnitPixelPalette) {
graphics.fillStyle(0x5b3824, 1);
graphics.fillRect(10, 28, 28, 8);
graphics.fillRect(8, 31, 6, 4);
graphics.fillRect(34, 25, 5, 7);
graphics.fillStyle(0x3a2418, 1);
graphics.fillRect(12, 36, 4, 7);
graphics.fillRect(20, 35, 4, 8);
graphics.fillRect(30, 35, 4, 8);
graphics.fillStyle(0x8c5a36, 1);
graphics.fillRect(13, 27, 19, 3);
graphics.fillStyle(colors.accent, 1);
graphics.fillRect(18, 29, 10, 2);
}
private drawWeapon(graphics: Phaser.GameObjects.Graphics, colors: UnitPixelPalette) {
graphics.fillStyle(colors.weapon, 1);
if (colors.spear) {
graphics.fillRect(36, 8, 2, 31);
graphics.fillRect(35, 6, 4, 3);
graphics.fillRect(34, 9, 6, 2);
return;
}
if (colors.halberd) {
graphics.fillRect(36, 6, 2, 34);
graphics.fillRect(32, 7, 9, 2);
graphics.fillRect(37, 9, 6, 4);
graphics.fillRect(34, 13, 3, 3);
return;
}
graphics.fillRect(36, 15, 2, 22);
graphics.fillRect(34, 14, 6, 2);
graphics.fillRect(37, 12, 2, 4);
}
private drawBody(graphics: Phaser.GameObjects.Graphics, colors: UnitPixelPalette) {
graphics.fillStyle(colors.shade, 1);
graphics.fillRect(17, 21, 16, 18);
graphics.fillRect(14, 26, 6, 10);
graphics.fillRect(31, 26, 5, 10);
graphics.fillStyle(colors.robe, 1);
graphics.fillRect(18, 20, 14, 16);
graphics.fillRect(15, 25, 6, 9);
graphics.fillRect(31, 25, 5, 9);
graphics.fillRect(19, 36, 5, 6);
graphics.fillRect(27, 36, 5, 6);
graphics.fillStyle(colors.trim, 1);
graphics.fillRect(19, 21, 12, 2);
graphics.fillRect(20, 27, 10, 2);
graphics.fillRect(18, 34, 15, 2);
graphics.fillRect(22, 23, 2, 12);
graphics.fillStyle(0x0a0c10, 1);
graphics.fillRect(19, 42, 6, 2);
graphics.fillRect(27, 42, 6, 2);
graphics.fillStyle(colors.skin, 1);
graphics.fillRect(13, 28, 3, 5);
graphics.fillRect(35, 28, 3, 5);
}
private drawHead(graphics: Phaser.GameObjects.Graphics, colors: UnitPixelPalette) {
graphics.fillStyle(colors.skin, 1);
graphics.fillRect(18, 10, 12, 10);
graphics.fillRect(16, 13, 3, 5);
graphics.fillRect(29, 13, 3, 5);
graphics.fillStyle(colors.hair, 1);
graphics.fillRect(17, 8, 14, 4);
graphics.fillRect(16, 11, 4, 7);
graphics.fillRect(29, 11, 3, 7);
graphics.fillStyle(colors.accent, 1);
graphics.fillRect(19, 7, 9, 2);
graphics.fillRect(21, 5, 5, 2);
graphics.fillStyle(0x050609, 1);
graphics.fillRect(20, 14, 2, 1);
graphics.fillRect(27, 14, 2, 1);
graphics.fillRect(23, 18, 4, 1);
if (colors.beard) {
graphics.fillStyle(0x2a1712, 1);
graphics.fillRect(20, 19, 9, 6);
graphics.fillRect(22, 25, 5, 5);
graphics.fillStyle(0x160d0a, 1);
graphics.fillRect(23, 28, 3, 4);
}
}
}