diff --git a/docs/roadmap.md b/docs/roadmap.md index fb99dac..063c242 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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 diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index 6810aa3..0d64a7b 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -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(); 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 = []; diff --git a/src/game/scenes/BootScene.ts b/src/game/scenes/BootScene.ts index b404252..cfec790 100644 --- a/src/game/scenes/BootScene.ts +++ b/src/game/scenes/BootScene.ts @@ -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); + } + } }