diff --git a/docs/roadmap.md b/docs/roadmap.md index 412e7fa..fb99dac 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -16,7 +16,7 @@ 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 12x12 map, terrain colors, unit placement, and movement range preview +- First battle scene with a high-resolution battlefield background, large 12x12 tactical grid, pixel-style unit sprites, and movement range preview - Flow verification script from title to first battle ## Next Steps diff --git a/src/assets/images/battle/first-battle-map.png b/src/assets/images/battle/first-battle-map.png new file mode 100644 index 0000000..5198dde Binary files /dev/null and b/src/assets/images/battle/first-battle-map.png differ diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index 990c6ab..6810aa3 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -3,9 +3,6 @@ import { soundDirector } from '../audio/SoundDirector'; 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 = { plain: 0x6d8056, forest: 0x355f42, @@ -13,7 +10,33 @@ const terrainColor: Record = { fort: 0x8b6f48 }; +const unitTexture: Record = { + 'liu-bei': 'unit-liu-bei', + 'guan-yu': 'unit-guan-yu', + 'zhang-fei': 'unit-zhang-fei', + 'rebel-a': 'unit-rebel', + 'rebel-b': 'unit-rebel', + 'rebel-c': 'unit-rebel-cavalry', + 'rebel-leader': 'unit-rebel-leader' +}; + +type BattleLayout = { + mapX: number; + mapY: number; + mapWidth: number; + mapHeight: number; + gridX: number; + gridY: number; + gridSize: number; + tileSize: number; + panelX: number; + panelY: number; + panelWidth: number; + panelHeight: number; +}; + export class BattleScene extends Phaser.Scene { + private layout!: BattleLayout; private selectedUnit?: UnitData; private infoText?: Phaser.GameObjects.Text; private turnText?: Phaser.GameObjects.Text; @@ -25,105 +48,186 @@ export class BattleScene extends Phaser.Scene { create() { const { width, height } = this.scale; + this.layout = this.createLayout(width, height); soundDirector.playMusic('battle-prep'); - this.add.rectangle(0, 0, width, height, 0x0f1418).setOrigin(0); + + this.add.rectangle(0, 0, width, height, 0x080b0d).setOrigin(0); this.drawMap(); this.drawUnits(); - this.drawSidePanel(width, height); - this.setInfo('아군 차례입니다. 유비를 선택해 이동 범위를 확인하세요.'); + this.drawSidePanel(); + this.setInfo('아군 차례입니다.\n유비를 선택해 이동 범위를 확인하세요.'); + } + + private createLayout(width: number, height: number): BattleLayout { + const margin = 24; + const gap = 20; + const panelWidth = Phaser.Math.Clamp(Math.floor(width * 0.27), 320, 360); + const mapWidth = width - panelWidth - gap - margin * 2; + const mapHeight = height - margin * 2; + const gridSize = Math.floor(Math.min(mapHeight - 36, mapWidth - 72) / firstBattleMap.width) * firstBattleMap.width; + const tileSize = gridSize / firstBattleMap.width; + const mapX = margin; + const mapY = margin; + + return { + mapX, + mapY, + mapWidth, + mapHeight, + gridX: mapX + Math.floor((mapWidth - gridSize) / 2), + gridY: mapY + Math.floor((mapHeight - gridSize) / 2), + gridSize, + tileSize, + panelX: mapX + mapWidth + gap, + panelY: margin, + panelWidth, + panelHeight: mapHeight + }; } private drawMap() { + const layout = this.layout; + const background = this.add.image( + layout.mapX + layout.mapWidth / 2, + layout.mapY + layout.mapHeight / 2, + 'battle-map-first' + ); + const source = this.textures.get('battle-map-first').getSourceImage(); + const coverScale = Math.max(layout.mapWidth / source.width, layout.mapHeight / source.height); + background.setScale(coverScale); + background.setDepth(0); + + const maskShape = this.add.graphics(); + maskShape.fillStyle(0xffffff, 1); + maskShape.fillRect(layout.mapX, layout.mapY, layout.mapWidth, layout.mapHeight); + maskShape.setAlpha(0); + background.setMask(maskShape.createGeometryMask()); + + this.add.rectangle(layout.mapX, layout.mapY, layout.mapWidth, layout.mapHeight, 0x06090b, 0.16).setOrigin(0).setDepth(1); + this.add + .rectangle(layout.mapX, layout.mapY, layout.mapWidth, layout.mapHeight) + .setOrigin(0) + .setStrokeStyle(2, palette.gold, 0.62) + .setDepth(8); + 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); + 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); + tile.setOrigin(0); + tile.setStrokeStyle(1, 0x0a1014, 0.5); + tile.setDepth(2); }); }); + + this.add + .rectangle(layout.gridX, layout.gridY, layout.gridSize, layout.gridSize) + .setOrigin(0) + .setStrokeStyle(2, 0xf1d489, 0.48) + .setDepth(3); } 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( - mapOffset.x + unit.x * tileSize + tileSize / 2, - mapOffset.y + unit.y * tileSize + tileSize / 2, - unit.faction === 'ally' ? 'unit-ally' : 'unit-enemy' + 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); + sprite.setDepth(6); sprite.setInteractive({ useHandCursor: unit.faction === 'ally' }); sprite.on('pointerdown', () => this.selectUnit(unit)); - this.add.text(sprite.x - 22, sprite.y + 22, unit.name, { + const label = this.add.text(sprite.x, sprite.y + layout.tileSize * 0.35, unit.name, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '13px', color: unit.faction === 'ally' ? '#e7edf7' : '#ffebe7', - backgroundColor: '#10131a', - padding: { left: 4, right: 4, top: 2, bottom: 2 } + backgroundColor: 'rgba(8,10,14,0.78)', + padding: { left: 5, right: 5, top: 2, bottom: 2 } }); + label.setOrigin(0.5, 0); + label.setDepth(7); }); } - 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', + private drawSidePanel() { + const { panelX, panelY, panelWidth, panelHeight } = this.layout; + this.add.rectangle(panelX, panelY, panelWidth, panelHeight, palette.panel, 0.96).setOrigin(0); + this.add.rectangle(panelX, panelY, 3, panelHeight, palette.gold, 0.82).setOrigin(0); + + this.add.text(panelX + 24, panelY + 28, '첫 전투', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '30px', color: '#e8dfca', - fontStyle: '700' + fontStyle: '700', + padding: { top: 4, bottom: 4 } }); - this.turnText = this.add.text(panelX + 30, 164, '1턴 / 아군', { + this.turnText = this.add.text(panelX + 24, panelY + 88, '1턴 / 아군', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '22px', color: '#d8b15f' }); - this.infoText = this.add.text(panelX + 30, 224, '', { + this.infoText = this.add.text(panelX + 24, panelY + 144, '', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '20px', color: '#e8dfca', - wordWrap: { width: width - panelX - 124, useAdvancedWrap: true }, + wordWrap: { width: panelWidth - 48, useAdvancedWrap: true }, lineSpacing: 8 }); - this.add.text(panelX + 30, height - 186, '승리 조건: 적 전멸', { + this.add.text(panelX + 24, panelY + panelHeight - 166, '승리 조건: 적 전멸', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '18px', color: '#9aa3ad' }); - this.add.text(panelX + 30, height - 148, '패배 조건: 유비 퇴각', { + this.add.text(panelX + 24, panelY + panelHeight - 126, '패배 조건: 유비 퇴각', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '18px', color: '#9aa3ad' }); - this.add.text(panelX + 30, height - 110, '다음 단계: 이동/공격/AI 구현', { + this.add.text(panelX + 24, panelY + panelHeight - 86, '다음 단계: 이동 / 공격 / AI', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', 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}`); + this.setInfo(`${unit.name}: ${unit.className}\nHP ${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}`); + this.setInfo(`${unit.name} (${unit.className})\nHP ${unit.hp}/${unit.maxHp}\n공격 ${unit.attack} / 이동 ${unit.move}`); } 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( - mapOffset.x + x * tileSize, - mapOffset.y + y * tileSize, - tileSize, - tileSize, + 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.6); + marker.setStrokeStyle(1, palette.blue, 0.68); + marker.setDepth(4); this.markers.push(marker); } } diff --git a/src/game/scenes/BootScene.ts b/src/game/scenes/BootScene.ts index e41002c..b404252 100644 --- a/src/game/scenes/BootScene.ts +++ b/src/game/scenes/BootScene.ts @@ -1,4 +1,5 @@ import Phaser from 'phaser'; +import firstBattleMapUrl from '../../assets/images/battle/first-battle-map.png'; import titleBackgroundUrl from '../../assets/images/taoyuan-oath-title.png'; import storyChaosUrl from '../../assets/images/story/01-yellow-turban-chaos.png'; import storyLiuBeiUrl from '../../assets/images/story/02-liu-bei-resolve.png'; @@ -22,6 +23,7 @@ export class BootScene extends Phaser.Scene { this.load.image('story-three-heroes', storyThreeHeroesUrl); this.load.image('story-militia', storyMilitiaUrl); this.load.image('story-sortie', storySortieUrl); + this.load.image('battle-map-first', firstBattleMapUrl); this.load.image('portrait-liu-bei', liuBeiPortraitUrl); this.load.image('portrait-guan-yu', guanYuPortraitUrl); this.load.image('portrait-zhang-fei', zhangFeiPortraitUrl); @@ -34,8 +36,7 @@ export class BootScene extends Phaser.Scene { private createGeneratedTextures() { this.createPetalTexture(); - this.createUnitTexture('unit-ally', palette.blue, 0xe7edf7); - this.createUnitTexture('unit-enemy', palette.red, 0xffebe7); + this.createPixelUnitTextures(); } private createPetalTexture() { @@ -48,17 +49,144 @@ export class BootScene extends Phaser.Scene { graphics.destroy(); } - private createUnitTexture(key: string, primary: number, accent: number) { + private createPixelUnitTextures() { + this.createPixelUnitTexture('unit-liu-bei', { + robe: palette.blue, + trim: 0xe7edf7, + skin: 0xf0c79a, + hair: 0x1e1b17, + weapon: 0xd8d0ba, + accent: 0xd8b15f + }); + this.createPixelUnitTexture('unit-guan-yu', { + robe: 0x2f7d45, + trim: 0xd7e6c5, + skin: 0xd59b6e, + hair: 0x241611, + weapon: 0xc7d2d8, + accent: 0xb64232, + beard: true, + halberd: true + }); + this.createPixelUnitTexture('unit-zhang-fei', { + robe: 0x5b2b33, + trim: 0xefc67c, + skin: 0xb97955, + hair: 0x16110f, + weapon: 0xcfd8d8, + accent: 0x15191f, + spear: true + }); + this.createPixelUnitTexture('unit-rebel', { + robe: 0xc8a032, + trim: 0x6e4f24, + skin: 0xbc8053, + hair: 0x242015, + weapon: 0xbfc5bd, + accent: 0xf1d157, + spear: true + }); + this.createPixelUnitTexture('unit-rebel-cavalry', { + robe: 0xd0aa3f, + trim: 0x7a4b2a, + skin: 0xbc8053, + hair: 0x262017, + weapon: 0xbfc5bd, + accent: 0x56351f, + mounted: true + }); + this.createPixelUnitTexture('unit-rebel-leader', { + robe: 0x9e7130, + trim: 0xf4d66f, + skin: 0xc98b5d, + hair: 0x1e1710, + weapon: 0xd4d7c6, + accent: 0xe4c43d, + halberd: true, + cape: true + }); + } + + 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; + } + ) { 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.fillStyle(0x07090c, 0.42); + graphics.fillRect(6, 27, 20, 3); + + 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); + } + + if (colors.cape) { + graphics.fillStyle(0x6c2722, 1); + graphics.fillRect(10, 13, 13, 13); + graphics.fillRect(8, 18, 5, 7); + } + + 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); + } + + 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.destroy(); + this.textures.get(key).setFilter(Phaser.Textures.FilterMode.NEAREST); } }