From 3538a3aaaa3c114d80d73460948fab6af877ef22 Mon Sep 17 00:00:00 2001 From: Wickedness Date: Mon, 22 Jun 2026 14:56:22 +0900 Subject: [PATCH] Expand first battle map and camera controls --- scripts/verify-flow.mjs | 27 ++ src/game/data/battleRules.ts | 45 ++- src/game/data/scenario.ts | 203 +++++++++++-- src/game/scenes/BattleScene.ts | 534 +++++++++++++++++++++++++++++---- 4 files changed, 717 insertions(+), 92 deletions(-) diff --git a/scripts/verify-flow.mjs b/scripts/verify-flow.mjs index 8ca9745..c1de40e 100644 --- a/scripts/verify-flow.mjs +++ b/scripts/verify-flow.mjs @@ -74,6 +74,33 @@ try { throw new Error(`Debug battle state was not available: ${JSON.stringify(result.battleState)}`); } + const cameraBeforeScroll = result.battleState.camera; + if ( + !cameraBeforeScroll || + cameraBeforeScroll.mapWidth <= cameraBeforeScroll.visibleColumns || + cameraBeforeScroll.mapHeight <= cameraBeforeScroll.visibleRows + ) { + throw new Error(`Expected a scrollable battle map: ${JSON.stringify(cameraBeforeScroll)}`); + } + + await page.mouse.move(768, 360); + await page.waitForTimeout(520); + const cameraAfterEdgeScroll = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()?.camera); + if (!cameraAfterEdgeScroll || cameraAfterEdgeScroll.x <= cameraBeforeScroll.x) { + throw new Error(`Expected edge scrolling to move camera right: ${JSON.stringify({ cameraBeforeScroll, cameraAfterEdgeScroll })}`); + } + + await page.mouse.click(1138, 553); + await page.waitForTimeout(160); + const cameraAfterMiniMapClick = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()?.camera); + if ( + !cameraAfterMiniMapClick || + cameraAfterMiniMapClick.x < cameraAfterMiniMapClick.mapWidth - cameraAfterMiniMapClick.visibleColumns || + cameraAfterMiniMapClick.y !== 0 + ) { + throw new Error(`Expected minimap click to move camera to the northeast: ${JSON.stringify(cameraAfterMiniMapClick)}`); + } + const readyUnits = result.battleState.units.filter((unit) => !unit.acted); if (!readyUnits.some((unit) => unit.animating && unit.animationKey?.includes('-idle-'))) { throw new Error(`Expected ready units to loop idle frames: ${JSON.stringify(readyUnits)}`); diff --git a/src/game/data/battleRules.ts b/src/game/data/battleRules.ts index a9bd196..dd56057 100644 --- a/src/game/data/battleRules.ts +++ b/src/game/data/battleRules.ts @@ -1,4 +1,4 @@ -export type TerrainType = 'plain' | 'road' | 'forest' | 'hill' | 'village' | 'fort' | 'camp'; +export type TerrainType = 'plain' | 'road' | 'forest' | 'hill' | 'village' | 'fort' | 'camp' | 'river' | 'cliff'; export type UnitClassKey = 'lord' | 'infantry' | 'cavalry' | 'spearman' | 'archer' | 'bandit' | 'yellowTurban' | 'rebelLeader'; @@ -8,6 +8,9 @@ export type TerrainRule = { alpha: number; moveCost: number; defenseBonus: number; + passable?: boolean; + recoveryHp?: number; + moraleBonus?: number; }; export type UnitClassDefinition = { @@ -25,9 +28,11 @@ export const terrainRules: Record = { road: { label: '길', color: 0xb08b55, alpha: 0.1, moveCost: 1, defenseBonus: 0 }, forest: { label: '숲', color: 0x355f42, alpha: 0.17, moveCost: 2, defenseBonus: 10 }, hill: { label: '언덕', color: 0x82724e, alpha: 0.15, moveCost: 2, defenseBonus: 8 }, - village: { label: '마을', color: 0x8d6f4b, alpha: 0.2, moveCost: 1, defenseBonus: 12 }, - fort: { label: '요새', color: 0x735c46, alpha: 0.22, moveCost: 1, defenseBonus: 18 }, - camp: { label: '진영', color: 0x4f6a73, alpha: 0.17, moveCost: 1, defenseBonus: 15 } + village: { label: '마을', color: 0x8d6f4b, alpha: 0.2, moveCost: 1, defenseBonus: 12, recoveryHp: 8, moraleBonus: 3 }, + fort: { label: '요새', color: 0x735c46, alpha: 0.22, moveCost: 1, defenseBonus: 18, recoveryHp: 10, moraleBonus: 4 }, + camp: { label: '진영', color: 0x4f6a73, alpha: 0.17, moveCost: 1, defenseBonus: 15, recoveryHp: 6, moraleBonus: 4 }, + river: { label: '강', color: 0x2c6687, alpha: 0.24, moveCost: 99, defenseBonus: -4, passable: false }, + cliff: { label: '절벽', color: 0x39342e, alpha: 0.28, moveCost: 99, defenseBonus: 20, passable: false } }; export const unitClasses: Record = { @@ -44,7 +49,9 @@ export const unitClasses: Record = { hill: 95, village: 110, fort: 115, - camp: 115 + camp: 115, + river: 0, + cliff: 0 } }, infantry: { @@ -60,7 +67,9 @@ export const unitClasses: Record = { hill: 105, village: 115, fort: 120, - camp: 115 + camp: 115, + river: 0, + cliff: 0 }, movementCosts: { forest: 1, @@ -82,7 +91,9 @@ export const unitClasses: Record = { hill: 80, village: 95, fort: 90, - camp: 100 + camp: 100, + river: 0, + cliff: 0 }, movementCosts: { road: 1, @@ -103,7 +114,9 @@ export const unitClasses: Record = { hill: 105, village: 105, fort: 110, - camp: 110 + camp: 110, + river: 0, + cliff: 0 } }, archer: { @@ -119,7 +132,9 @@ export const unitClasses: Record = { hill: 115, village: 100, fort: 120, - camp: 105 + camp: 105, + river: 0, + cliff: 0 }, movementCosts: { hill: 1 @@ -138,7 +153,9 @@ export const unitClasses: Record = { hill: 115, village: 100, fort: 90, - camp: 95 + camp: 95, + river: 0, + cliff: 0 }, movementCosts: { forest: 1, @@ -158,7 +175,9 @@ export const unitClasses: Record = { hill: 105, village: 100, fort: 90, - camp: 95 + camp: 95, + river: 0, + cliff: 0 }, movementCosts: { forest: 1 @@ -177,7 +196,9 @@ export const unitClasses: Record = { hill: 110, village: 105, fort: 105, - camp: 110 + camp: 110, + river: 0, + cliff: 0 }, movementCosts: { forest: 1, diff --git a/src/game/data/scenario.ts b/src/game/data/scenario.ts index 6ce848e..f773b3c 100644 --- a/src/game/data/scenario.ts +++ b/src/game/data/scenario.ts @@ -142,21 +142,27 @@ export const prologuePages: StoryPage[] = [ ]; export const firstBattleMap: BattleMap = { - width: 12, - height: 12, + width: 20, + height: 18, terrain: [ - ['plain', 'plain', 'forest', 'forest', 'plain', 'road', 'road', 'hill', 'hill', 'plain', 'plain', 'plain'], - ['plain', 'forest', 'forest', 'plain', 'plain', 'road', 'road', 'plain', 'hill', 'plain', 'plain', 'plain'], - ['plain', 'forest', 'plain', 'plain', 'plain', 'road', 'forest', 'plain', 'plain', 'plain', 'plain', 'plain'], - ['plain', 'plain', 'plain', 'hill', 'hill', 'road', 'forest', 'forest', 'plain', 'plain', 'plain', 'plain'], - ['plain', 'plain', 'plain', 'hill', 'plain', 'road', 'road', 'forest', 'plain', 'plain', 'fort', 'plain'], - ['plain', 'plain', 'plain', 'plain', 'plain', 'village', 'road', 'plain', 'plain', 'plain', 'forest', 'forest'], - ['plain', 'plain', 'plain', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'plain', 'forest', 'plain'], - ['forest', 'forest', 'plain', 'plain', 'plain', 'road', 'plain', 'hill', 'hill', 'plain', 'plain', 'plain'], - ['forest', 'plain', 'plain', 'plain', 'plain', 'road', 'plain', 'hill', 'plain', 'plain', 'plain', 'plain'], - ['plain', 'camp', 'plain', 'forest', 'forest', 'road', 'road', 'plain', 'plain', 'plain', 'plain', 'plain'], - ['plain', 'camp', 'camp', 'plain', 'forest', 'plain', 'road', 'plain', 'plain', 'forest', 'forest', 'plain'], - ['plain', 'plain', 'plain', 'plain', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'forest', 'plain'] + ['forest', 'forest', 'hill', 'hill', 'cliff', 'cliff', 'hill', 'plain', 'road', 'road', 'plain', 'forest', 'hill', 'hill', 'plain', 'plain', 'forest', 'hill', 'hill', 'plain'], + ['forest', 'hill', 'hill', 'plain', 'cliff', 'hill', 'plain', 'plain', 'road', 'road', 'plain', 'forest', 'forest', 'hill', 'plain', 'plain', 'plain', 'hill', 'fort', 'plain'], + ['plain', 'forest', 'plain', 'plain', 'hill', 'hill', 'plain', 'road', 'road', 'plain', 'plain', 'plain', 'forest', 'plain', 'plain', 'plain', 'fort', 'fort', 'plain', 'plain'], + ['plain', 'plain', 'plain', 'forest', 'hill', 'plain', 'road', 'road', 'plain', 'plain', 'forest', 'plain', 'plain', 'plain', 'hill', 'plain', 'fort', 'plain', 'plain', 'forest'], + ['plain', 'plain', 'forest', 'forest', 'plain', 'plain', 'road', 'plain', 'plain', 'forest', 'forest', 'plain', 'river', 'river', 'plain', 'plain', 'plain', 'plain', 'forest', 'forest'], + ['plain', 'plain', 'plain', 'forest', 'plain', 'road', 'road', 'plain', 'forest', 'plain', 'plain', 'plain', 'river', 'river', 'plain', 'village', 'plain', 'plain', 'plain', 'forest'], + ['plain', 'plain', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'plain', 'plain', 'hill', 'plain', 'river', 'river', 'plain', 'plain', 'plain', 'plain', 'hill', 'plain'], + ['hill', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'hill', 'hill', 'plain', 'hill', 'plain', 'road', 'road', 'plain', 'plain', 'forest', 'plain', 'plain', 'plain'], + ['hill', 'hill', 'plain', 'road', 'forest', 'plain', 'plain', 'hill', 'plain', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'forest', 'forest', 'plain', 'plain', 'plain'], + ['plain', 'plain', 'road', 'road', 'forest', 'plain', 'plain', 'plain', 'village', 'plain', 'road', 'road', 'plain', 'plain', 'plain', 'plain', 'plain', 'hill', 'plain', 'plain'], + ['plain', 'road', 'road', 'plain', 'plain', 'plain', 'forest', 'plain', 'road', 'road', 'road', 'plain', 'plain', 'forest', 'plain', 'plain', 'hill', 'hill', 'plain', 'plain'], + ['road', 'road', 'plain', 'plain', 'forest', 'forest', 'plain', 'plain', 'river', 'river', 'plain', 'plain', 'forest', 'forest', 'plain', 'hill', 'hill', 'plain', 'plain', 'plain'], + ['plain', 'road', 'plain', 'forest', 'forest', 'plain', 'plain', 'plain', 'river', 'river', 'plain', 'village', 'plain', 'plain', 'plain', 'plain', 'forest', 'plain', 'plain', 'plain'], + ['plain', 'road', 'plain', 'plain', 'plain', 'plain', 'hill', 'plain', 'river', 'river', 'plain', 'plain', 'plain', 'camp', 'plain', 'plain', 'forest', 'plain', 'plain', 'hill'], + ['camp', 'road', 'plain', 'plain', 'hill', 'plain', 'hill', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'plain', 'plain', 'forest', 'plain', 'plain', 'hill', 'hill'], + ['camp', 'camp', 'road', 'plain', 'plain', 'plain', 'plain', 'forest', 'plain', 'plain', 'road', 'road', 'plain', 'forest', 'plain', 'plain', 'plain', 'plain', 'plain', 'hill'], + ['plain', 'camp', 'road', 'plain', 'forest', 'plain', 'plain', 'forest', 'forest', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'plain', 'forest', 'forest', 'plain', 'plain'], + ['plain', 'plain', 'road', 'road', 'plain', 'plain', 'forest', 'forest', 'plain', 'plain', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'plain', 'plain', 'plain', 'plain'] ] }; @@ -180,7 +186,7 @@ export const firstBattleUnits: UnitData[] = [ accessory: { itemId: 'peach-charm', level: 1, exp: 0 } }, x: 1, - y: 9 + y: 15 }, { id: 'guan-yu', @@ -201,7 +207,7 @@ export const firstBattleUnits: UnitData[] = [ accessory: { itemId: 'war-manual', level: 1, exp: 0 } }, x: 2, - y: 10 + y: 15 }, { id: 'zhang-fei', @@ -222,7 +228,7 @@ export const firstBattleUnits: UnitData[] = [ accessory: { itemId: 'bravery-token', level: 1, exp: 0 } }, x: 1, - y: 10 + y: 16 }, { id: 'rebel-a', @@ -242,8 +248,8 @@ export const firstBattleUnits: UnitData[] = [ armor: { itemId: 'rebel-vest', level: 1, exp: 4 }, accessory: { itemId: 'yellow-scarf-charm', level: 1, exp: 0 } }, - x: 8, - y: 2 + x: 10, + y: 12 }, { id: 'rebel-b', @@ -263,8 +269,8 @@ export const firstBattleUnits: UnitData[] = [ armor: { itemId: 'rebel-vest', level: 1, exp: 5 }, accessory: { itemId: 'yellow-scarf-charm', level: 1, exp: 0 } }, - x: 9, - y: 3 + x: 12, + y: 12 }, { id: 'rebel-c', @@ -284,8 +290,155 @@ export const firstBattleUnits: UnitData[] = [ armor: { itemId: 'cloth-armor', level: 1, exp: 6 }, accessory: { itemId: 'wind-quiver', level: 1, exp: 0 } }, - x: 10, - y: 2 + x: 13, + y: 11 + }, + { + id: 'rebel-d', + name: '황건병', + faction: 'enemy', + className: '황건병', + classKey: 'yellowTurban', + level: 1, + exp: 8, + hp: 21, + maxHp: 21, + attack: 6, + move: 3, + stats: { might: 51, intelligence: 31, leadership: 39, agility: 44, luck: 39 }, + equipment: { + weapon: { itemId: 'yellow-turban-saber', level: 1, exp: 8 }, + armor: { itemId: 'rebel-vest', level: 1, exp: 5 }, + accessory: { itemId: 'yellow-scarf-charm', level: 1, exp: 0 } + }, + x: 6, + y: 8 + }, + { + id: 'rebel-e', + name: '황건병', + faction: 'enemy', + className: '황건병', + classKey: 'yellowTurban', + level: 1, + exp: 8, + hp: 21, + maxHp: 21, + attack: 6, + move: 3, + stats: { might: 50, intelligence: 33, leadership: 37, agility: 46, luck: 41 }, + equipment: { + weapon: { itemId: 'yellow-turban-saber', level: 1, exp: 8 }, + armor: { itemId: 'rebel-vest', level: 1, exp: 5 }, + accessory: { itemId: 'yellow-scarf-charm', level: 1, exp: 0 } + }, + x: 5, + y: 9 + }, + { + id: 'rebel-cavalry-a', + name: '황건기병', + faction: 'enemy', + className: '경기병', + classKey: 'cavalry', + level: 2, + exp: 14, + hp: 24, + maxHp: 24, + attack: 8, + move: 5, + stats: { might: 58, intelligence: 34, leadership: 45, agility: 62, luck: 42 }, + equipment: { + weapon: { itemId: 'yellow-turban-saber', level: 1, exp: 12 }, + armor: { itemId: 'rebel-vest', level: 1, exp: 6 }, + accessory: { itemId: 'grain-pouch', level: 1, exp: 0 } + }, + x: 9, + y: 9 + }, + { + id: 'rebel-archer-b', + name: '황건궁병', + faction: 'enemy', + className: '궁병', + classKey: 'archer', + level: 2, + exp: 12, + hp: 19, + maxHp: 19, + attack: 7, + move: 3, + stats: { might: 53, intelligence: 42, leadership: 44, agility: 57, luck: 44 }, + equipment: { + weapon: { itemId: 'short-bow', level: 1, exp: 12 }, + armor: { itemId: 'cloth-armor', level: 1, exp: 6 }, + accessory: { itemId: 'wind-quiver', level: 1, exp: 0 } + }, + x: 15, + y: 5 + }, + { + id: 'rebel-cavalry-b', + name: '황건기병', + faction: 'enemy', + className: '경기병', + classKey: 'cavalry', + level: 2, + exp: 16, + hp: 25, + maxHp: 25, + attack: 8, + move: 5, + stats: { might: 60, intelligence: 35, leadership: 48, agility: 63, luck: 45 }, + equipment: { + weapon: { itemId: 'yellow-turban-saber', level: 1, exp: 14 }, + armor: { itemId: 'rebel-vest', level: 1, exp: 7 }, + accessory: { itemId: 'grain-pouch', level: 1, exp: 0 } + }, + x: 14, + y: 7 + }, + { + id: 'rebel-f', + name: '황건병', + faction: 'enemy', + className: '황건병', + classKey: 'yellowTurban', + level: 2, + exp: 10, + hp: 23, + maxHp: 23, + attack: 7, + move: 3, + stats: { might: 54, intelligence: 35, leadership: 43, agility: 47, luck: 42 }, + equipment: { + weapon: { itemId: 'yellow-turban-saber', level: 1, exp: 10 }, + armor: { itemId: 'rebel-vest', level: 1, exp: 6 }, + accessory: { itemId: 'yellow-scarf-charm', level: 1, exp: 0 } + }, + x: 15, + y: 3 + }, + { + id: 'rebel-g', + name: '황건병', + faction: 'enemy', + className: '황건병', + classKey: 'yellowTurban', + level: 2, + exp: 11, + hp: 23, + maxHp: 23, + attack: 7, + move: 3, + stats: { might: 55, intelligence: 36, leadership: 44, agility: 48, luck: 43 }, + equipment: { + weapon: { itemId: 'yellow-turban-saber', level: 1, exp: 10 }, + armor: { itemId: 'rebel-vest', level: 1, exp: 6 }, + accessory: { itemId: 'yellow-scarf-charm', level: 1, exp: 0 } + }, + x: 17, + y: 3 }, { id: 'rebel-leader', @@ -305,8 +458,8 @@ export const firstBattleUnits: UnitData[] = [ armor: { itemId: 'lamellar-armor', level: 1, exp: 12 }, accessory: { itemId: 'grain-pouch', level: 1, exp: 0 } }, - x: 10, - y: 4 + x: 16, + y: 2 } ]; diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index 062380b..67900d3 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -25,6 +25,13 @@ const unitTexture: Record = { 'rebel-a': 'unit-rebel', 'rebel-b': 'unit-rebel', 'rebel-c': 'unit-rebel-archer', + 'rebel-d': 'unit-rebel', + 'rebel-e': 'unit-rebel', + 'rebel-f': 'unit-rebel', + 'rebel-g': 'unit-rebel', + 'rebel-cavalry-a': 'unit-rebel-cavalry', + 'rebel-cavalry-b': 'unit-rebel-cavalry', + 'rebel-archer-b': 'unit-rebel-archer', 'rebel-leader': 'unit-rebel-leader' }; @@ -43,6 +50,10 @@ type BattleLayout = { gridX: number; gridY: number; gridSize: number; + gridWidth: number; + gridHeight: number; + visibleColumns: number; + visibleRows: number; tileSize: number; panelX: number; panelY: number; @@ -50,6 +61,20 @@ type BattleLayout = { panelHeight: number; }; +type TerrainTileView = { + x: number; + y: number; + tile: Phaser.GameObjects.Rectangle; +}; + +type MiniMapLayout = { + x: number; + y: number; + width: number; + height: number; + cellSize: number; +}; + type UnitView = { sprite: Phaser.GameObjects.Sprite; label: Phaser.GameObjects.Text; @@ -308,6 +333,13 @@ const enemyAiByUnitId: Record = { 'rebel-a': 'aggressive', 'rebel-b': 'guard', 'rebel-c': 'hold', + 'rebel-d': 'guard', + 'rebel-e': 'aggressive', + 'rebel-f': 'guard', + 'rebel-g': 'guard', + 'rebel-cavalry-a': 'aggressive', + 'rebel-cavalry-b': 'aggressive', + 'rebel-archer-b': 'hold', 'rebel-leader': 'guard' }; @@ -344,7 +376,9 @@ const terrainDisplayLabels: Record = { hill: '언덕', village: '마을', fort: '요새', - camp: '진영' + camp: '진영', + river: '강', + cliff: '절벽' }; const mapMenuLabels: Partial> = { @@ -410,6 +444,16 @@ export class BattleScene extends Phaser.Scene { private pendingMove?: PendingMove; private battleOutcome?: BattleOutcome; private debugOverlay?: Phaser.GameObjects.Text; + private mapBackground?: Phaser.GameObjects.Image; + private mapMask?: Phaser.Display.Masks.GeometryMask; + private terrainTileViews: TerrainTileView[] = []; + private miniMapLayout?: MiniMapLayout; + private miniMapObjects: Phaser.GameObjects.GameObject[] = []; + private miniMapUnitDots = new Map(); + private miniMapViewport?: Phaser.GameObjects.Rectangle; + private cameraTileX = 0; + private cameraTileY = 0; + private edgeScrollElapsed = 0; constructor() { super('BattleScene'); @@ -419,6 +463,12 @@ export class BattleScene extends Phaser.Scene { this.resetBattleData(); const { width, height } = this.scale; this.layout = this.createLayout(width, height); + const startUnit = firstBattleUnits.find((unit) => unit.id === 'liu-bei') ?? firstBattleUnits[0]; + this.setCameraTilePosition( + (startUnit?.x ?? 0) - Math.floor(this.layout.visibleColumns / 2), + (startUnit?.y ?? 0) - Math.floor(this.layout.visibleRows / 2), + false + ); this.bondStates = this.createBondStates(); this.itemStocks = this.createItemStocks(); this.battleBuffs.clear(); @@ -465,8 +515,12 @@ export class BattleScene extends Phaser.Scene { 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 visibleColumns = Math.min(12, firstBattleMap.width); + const visibleRows = Math.min(12, firstBattleMap.height); + const tileSize = Math.floor(Math.min((mapHeight - 36) / visibleRows, (mapWidth - 72) / visibleColumns)); + const gridWidth = tileSize * visibleColumns; + const gridHeight = tileSize * visibleRows; + const gridSize = Math.min(gridWidth, gridHeight); const mapX = margin; const mapY = margin; @@ -475,9 +529,13 @@ export class BattleScene extends Phaser.Scene { mapY, mapWidth, mapHeight, - gridX: mapX + Math.floor((mapWidth - gridSize) / 2), - gridY: mapY + Math.floor((mapHeight - gridSize) / 2), + gridX: mapX + Math.floor((mapWidth - gridWidth) / 2), + gridY: mapY + Math.floor((mapHeight - gridHeight) / 2), gridSize, + gridWidth, + gridHeight, + visibleColumns, + visibleRows, tileSize, panelX: mapX + mapWidth + gap, panelY: margin, @@ -488,21 +546,19 @@ export class BattleScene extends Phaser.Scene { 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.mapMask = maskShape.createGeometryMask(); + const mapMask = this.mapMask; + + const background = this.add.image(0, 0, 'battle-map-first'); + background.setOrigin(0); + background.setDepth(0); + background.setDisplaySize(firstBattleMap.width * layout.tileSize, firstBattleMap.height * layout.tileSize); + background.setMask(mapMask); + this.mapBackground = background; this.add.rectangle(layout.mapX, layout.mapY, layout.mapWidth, layout.mapHeight, 0x06090b, 0.16).setOrigin(0).setDepth(1); this.add @@ -511,28 +567,25 @@ export class BattleScene extends Phaser.Scene { .setStrokeStyle(2, palette.gold, 0.62) .setDepth(8); + this.terrainTileViews = []; firstBattleMap.terrain.forEach((row, y) => { row.forEach((terrain, x) => { const terrainRule = getTerrainRule(terrain); - const tile = this.add.rectangle( - layout.gridX + x * layout.tileSize, - layout.gridY + y * layout.tileSize, - layout.tileSize, - layout.tileSize, - terrainRule.color, - terrainRule.alpha - ); + const tile = this.add.rectangle(0, 0, layout.tileSize, layout.tileSize, terrainRule.color, terrainRule.alpha); tile.setOrigin(0); - tile.setStrokeStyle(1, 0x0a1014, 0.5); + tile.setStrokeStyle(1, terrainRule.passable === false ? 0x111111 : 0x0a1014, terrainRule.passable === false ? 0.82 : 0.5); tile.setDepth(2); + tile.setMask(mapMask); + this.terrainTileViews.push({ x, y, tile }); }); }); this.add - .rectangle(layout.gridX, layout.gridY, layout.gridSize, layout.gridSize) + .rectangle(layout.gridX, layout.gridY, layout.gridWidth, layout.gridHeight) .setOrigin(0) .setStrokeStyle(2, 0xf1d489, 0.48) .setDepth(3); + this.updateCameraView(); } private drawUnits() { @@ -543,6 +596,9 @@ export class BattleScene extends Phaser.Scene { sprite.setName(`unit-${unit.id}`); sprite.setDisplaySize(this.layout.tileSize * sizeRatio, this.layout.tileSize * sizeRatio); sprite.setDepth(6); + if (this.mapMask) { + sprite.setMask(this.mapMask); + } sprite.setInteractive({ useHandCursor: unit.faction === 'ally' }); sprite.on('pointerdown', () => this.selectUnit(unit)); @@ -555,6 +611,9 @@ export class BattleScene extends Phaser.Scene { }); label.setOrigin(0.5, 0); label.setDepth(7); + if (this.mapMask) { + label.setMask(this.mapMask); + } const view = { sprite, label, textureBase, direction: 'south' as UnitDirection }; this.unitViews.set(unit.id, view); this.syncUnitMotion(unit, view); @@ -578,21 +637,233 @@ export class BattleScene extends Phaser.Scene { fontSize: '22px', color: '#d8b15f' }); - this.add.text(panelX + 24, panelY + panelHeight - 104, '승리 조건: 적 전멸', { + this.add.text(panelX + 24, panelY + panelHeight - 196, '승리: 적 전멸 패배: 유비 퇴각', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '18px', + fontSize: '15px', color: '#9aa3ad' }); - this.add.text(panelX + 24, panelY + panelHeight - 72, '패배 조건: 유비 퇴각', { + this.add.text(panelX + 24, panelY + panelHeight - 172, '가장자리 커서 / 미니맵 클릭으로 시야 이동', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '18px', + fontSize: '14px', color: '#9aa3ad' }); - this.add.text(panelX + 24, panelY + panelHeight - 40, '이동 후 명령: 공격 / 책략 / 도구 / 대기', { - fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '18px', - color: '#9aa3ad' + this.drawMiniMap(); + } + + update(_time: number, delta: number) { + this.updateEdgeScroll(delta); + } + + private drawMiniMap() { + const { panelX, panelY, panelWidth, panelHeight } = this.layout; + const maxWidth = panelWidth - 48; + const maxHeight = 132; + const cellSize = Math.floor(Math.min(maxWidth / firstBattleMap.width, maxHeight / firstBattleMap.height)); + const width = firstBattleMap.width * cellSize; + const height = firstBattleMap.height * cellSize; + const x = panelX + Math.floor((panelWidth - width) / 2); + const y = panelY + panelHeight - height - 24; + this.miniMapLayout = { x, y, width, height, cellSize }; + + const frame = this.add.rectangle(x - 5, y - 5, width + 10, height + 10, 0x0a1014, 0.92); + frame.setOrigin(0); + frame.setStrokeStyle(2, palette.gold, 0.72); + frame.setDepth(24); + this.miniMapObjects.push(frame); + + const hitArea = this.add.rectangle(x, y, width, height, 0x111820, 0.08); + hitArea.setOrigin(0); + hitArea.setInteractive({ useHandCursor: true }); + hitArea.setDepth(25); + hitArea.on('pointerdown', (pointer: Phaser.Input.Pointer) => this.handleMiniMapPointer(pointer)); + this.miniMapObjects.push(hitArea); + + firstBattleMap.terrain.forEach((row, tileY) => { + row.forEach((terrain, tileX) => { + const rule = getTerrainRule(terrain); + const cell = this.add.rectangle( + x + tileX * cellSize, + y + tileY * cellSize, + Math.max(1, cellSize - 1), + Math.max(1, cellSize - 1), + rule.color, + rule.passable === false ? 0.92 : 0.72 + ); + cell.setOrigin(0); + cell.setDepth(26); + cell.setInteractive({ useHandCursor: true }); + cell.on('pointerdown', (pointer: Phaser.Input.Pointer) => this.handleMiniMapPointer(pointer)); + this.miniMapObjects.push(cell); + }); }); + + firstBattleUnits.forEach((unit) => { + const dot = this.add.rectangle(0, 0, Math.max(3, cellSize), Math.max(3, cellSize), unit.faction === 'ally' ? 0x4aa9ff : 0xff715f, 0.96); + dot.setOrigin(0.5); + dot.setDepth(28); + this.miniMapUnitDots.set(unit.id, dot); + this.miniMapObjects.push(dot); + }); + + const viewport = this.add.rectangle(0, 0, this.layout.visibleColumns * cellSize, this.layout.visibleRows * cellSize); + viewport.setOrigin(0); + viewport.setStrokeStyle(2, 0xf7e6a1, 0.95); + viewport.setDepth(29); + this.miniMapViewport = viewport; + this.miniMapObjects.push(viewport); + this.updateMiniMap(); + } + + private handleMiniMapPointer(pointer: Phaser.Input.Pointer) { + if (!this.miniMapLayout || this.battleOutcome || this.phase === 'animating') { + return; + } + + const tileX = Phaser.Math.Clamp( + Math.floor((pointer.x - this.miniMapLayout.x) / this.miniMapLayout.cellSize), + 0, + firstBattleMap.width - 1 + ); + const tileY = Phaser.Math.Clamp( + Math.floor((pointer.y - this.miniMapLayout.y) / this.miniMapLayout.cellSize), + 0, + firstBattleMap.height - 1 + ); + this.centerCameraOnTile(tileX, tileY); + soundDirector.playSelect(); + } + + private updateEdgeScroll(delta: number) { + if ( + this.battleOutcome || + this.phase === 'animating' || + this.phase === 'resolved' || + this.commandMenuObjects.length > 0 || + this.mapMenuObjects.length > 0 || + this.turnPromptObjects.length > 0 || + this.combatCutInObjects.length > 0 || + this.resultObjects.length > 0 + ) { + this.edgeScrollElapsed = 0; + return; + } + + const pointer = this.input.activePointer; + const { gridX, gridY, gridWidth, gridHeight } = this.layout; + if (pointer.x < gridX || pointer.y < gridY || pointer.x > gridX + gridWidth || pointer.y > gridY + gridHeight) { + this.edgeScrollElapsed = 0; + return; + } + + const edge = 34; + const directionX = pointer.x < gridX + edge ? -1 : pointer.x > gridX + gridWidth - edge ? 1 : 0; + const directionY = pointer.y < gridY + edge ? -1 : pointer.y > gridY + gridHeight - edge ? 1 : 0; + if (directionX === 0 && directionY === 0) { + this.edgeScrollElapsed = 0; + return; + } + + this.edgeScrollElapsed += delta; + if (this.edgeScrollElapsed < 120) { + return; + } + + this.edgeScrollElapsed = 0; + this.setCameraTilePosition(this.cameraTileX + directionX, this.cameraTileY + directionY); + } + + private centerCameraOnTile(x: number, y: number) { + this.setCameraTilePosition(x - Math.floor(this.layout.visibleColumns / 2), y - Math.floor(this.layout.visibleRows / 2)); + } + + private setCameraTilePosition(x: number, y: number, refresh = true) { + const nextX = Phaser.Math.Clamp(Math.round(x), 0, this.maxCameraTileX()); + const nextY = Phaser.Math.Clamp(Math.round(y), 0, this.maxCameraTileY()); + const changed = nextX !== this.cameraTileX || nextY !== this.cameraTileY; + this.cameraTileX = nextX; + this.cameraTileY = nextY; + + if (refresh && changed) { + this.updateCameraView(); + } + } + + private maxCameraTileX() { + return Math.max(0, firstBattleMap.width - this.layout.visibleColumns); + } + + private maxCameraTileY() { + return Math.max(0, firstBattleMap.height - this.layout.visibleRows); + } + + private updateCameraView() { + const { gridX, gridY, tileSize } = this.layout; + const offsetX = this.cameraTileX * tileSize; + const offsetY = this.cameraTileY * tileSize; + this.mapBackground?.setPosition(gridX - offsetX, gridY - offsetY); + + this.terrainTileViews.forEach(({ x, y, tile }) => { + this.positionTileObject(tile, x, y); + }); + + this.markers.forEach((marker) => { + const x = marker.getData('tileX') as number | undefined; + const y = marker.getData('tileY') as number | undefined; + if (x !== undefined && y !== undefined) { + this.positionTileObject(marker, x, y); + } + }); + + firstBattleUnits.forEach((unit) => this.positionUnitView(unit)); + this.updateMiniMap(); + } + + private updateMiniMap() { + if (!this.miniMapLayout) { + return; + } + + const { x, y, cellSize } = this.miniMapLayout; + this.miniMapUnitDots.forEach((dot, unitId) => { + const unit = firstBattleUnits.find((candidate) => candidate.id === unitId); + if (!unit) { + return; + } + dot.setPosition(x + unit.x * cellSize + cellSize / 2, y + unit.y * cellSize + cellSize / 2); + dot.setVisible(unit.hp > 0); + dot.setFillStyle(unit.faction === 'ally' ? 0x4aa9ff : 0xff715f, unit.hp > 0 ? 0.96 : 0.18); + }); + + this.miniMapViewport?.setPosition(x + this.cameraTileX * cellSize, y + this.cameraTileY * cellSize); + } + + private positionTileObject(object: Phaser.GameObjects.Rectangle, x: number, y: number) { + const screenX = this.layout.gridX + (x - this.cameraTileX) * this.layout.tileSize; + const screenY = this.layout.gridY + (y - this.cameraTileY) * this.layout.tileSize; + object.setPosition(screenX, screenY); + object.setVisible(this.isTileVisible(x, y)); + } + + private positionUnitView(unit: UnitData) { + const view = this.unitViews.get(unit.id); + if (!view) { + return; + } + + view.sprite.setPosition(this.tileCenterX(unit.x), this.tileCenterY(unit.y)); + view.label.setPosition(view.sprite.x, view.sprite.y + this.layout.tileSize * 0.52); + const visible = this.isTileVisible(unit.x, unit.y); + view.sprite.setVisible(visible); + view.label.setVisible(visible); + } + + private isTileVisible(x: number, y: number) { + return ( + x >= this.cameraTileX && + y >= this.cameraTileY && + x < this.cameraTileX + this.layout.visibleColumns && + y < this.cameraTileY + this.layout.visibleRows + ); } private selectUnit(unit: UnitData) { @@ -607,6 +878,10 @@ export class BattleScene extends Phaser.Scene { return; } + if (!this.isTileVisible(unit.x, unit.y)) { + this.centerCameraOnTile(unit.x, unit.y); + } + if (this.phase === 'targeting' && this.selectedUnit) { if (unit.hp > 0 && this.targetingAction) { if (this.selectedUsable && this.selectedUsable.effect !== 'damage') { @@ -674,16 +949,22 @@ export class BattleScene extends Phaser.Scene { private showMoveRange(unit: UnitData) { this.reachableTiles(unit).forEach(({ x, y }) => { const marker = this.add.rectangle( - this.layout.gridX + x * this.layout.tileSize, - this.layout.gridY + y * this.layout.tileSize, + this.tileTopLeftX(x), + this.tileTopLeftY(y), this.layout.tileSize, this.layout.tileSize, palette.blue, 0.24 ); + marker.setData('tileX', x); + marker.setData('tileY', y); marker.setOrigin(0); marker.setStrokeStyle(1, palette.blue, 0.68); marker.setDepth(4); + if (this.mapMask) { + marker.setMask(this.mapMask); + } + marker.setVisible(this.isTileVisible(x, y)); marker.setInteractive({ useHandCursor: true }); marker.on('pointerover', () => marker.setFillStyle(palette.blue, 0.36)); marker.on('pointerout', () => marker.setFillStyle(palette.blue, 0.24)); @@ -714,6 +995,7 @@ export class BattleScene extends Phaser.Scene { this.pendingMove = { unit, fromX, fromY, toX: x, toY: y }; this.phase = 'command'; this.clearMarkers(); + this.updateMiniMap(); soundDirector.playSelect(); const targetX = this.tileCenterX(x); @@ -754,6 +1036,9 @@ export class BattleScene extends Phaser.Scene { const terrain = firstBattleMap.terrain[nextY][nextX]; const stepCost = this.movementCost(unit, terrain); + if (!Number.isFinite(stepCost)) { + return; + } const nextCost = current.cost + stepCost; const key = this.tileKey(nextX, nextY); if (nextCost > unit.move || nextCost >= (costs.get(key) ?? Number.POSITIVE_INFINITY)) { @@ -771,8 +1056,12 @@ export class BattleScene extends Phaser.Scene { } private movementCost(unit: UnitData, terrain: TerrainType) { + const terrainRule = getTerrainRule(terrain); + if (terrainRule.passable === false) { + return Number.POSITIVE_INFINITY; + } const unitClass = getUnitClass(unit.classKey); - return unitClass.movementCosts?.[terrain] ?? getTerrainRule(terrain).moveCost; + return unitClass.movementCosts?.[terrain] ?? terrainRule.moveCost; } private isInBounds(x: number, y: number) { @@ -788,11 +1077,19 @@ export class BattleScene extends Phaser.Scene { } private tileCenterX(x: number) { - return this.layout.gridX + x * this.layout.tileSize + this.layout.tileSize / 2; + return this.tileTopLeftX(x) + this.layout.tileSize / 2; } private tileCenterY(y: number) { - return this.layout.gridY + y * this.layout.tileSize + this.layout.tileSize / 2; + return this.tileTopLeftY(y) + this.layout.tileSize / 2; + } + + private tileTopLeftX(x: number) { + return this.layout.gridX + (x - this.cameraTileX) * this.layout.tileSize; + } + + private tileTopLeftY(y: number) { + return this.layout.gridY + (y - this.cameraTileY) * this.layout.tileSize; } private clearMarkers() { @@ -1139,16 +1436,22 @@ export class BattleScene extends Phaser.Scene { targets.forEach((target) => { const preview = this.combatPreview(attacker, target, action, usable); const marker = this.add.rectangle( - this.layout.gridX + target.x * this.layout.tileSize, - this.layout.gridY + target.y * this.layout.tileSize, + this.tileTopLeftX(target.x), + this.tileTopLeftY(target.y), this.layout.tileSize, this.layout.tileSize, 0xc93b30, 0.3 ); + marker.setData('tileX', target.x); + marker.setData('tileY', target.y); marker.setOrigin(0); marker.setStrokeStyle(2, 0xffd27a, 0.9); marker.setDepth(9); + if (this.mapMask) { + marker.setMask(this.mapMask); + } + marker.setVisible(this.isTileVisible(target.x, target.y)); marker.setInteractive({ useHandCursor: true }); marker.on('pointerover', () => { marker.setFillStyle(0xd9503f, 0.44); @@ -1167,16 +1470,22 @@ export class BattleScene extends Phaser.Scene { private showSupportTargets(user: UnitData, usable: BattleUsable, targets = this.supportTargets(user, usable)) { targets.forEach((target) => { const marker = this.add.rectangle( - this.layout.gridX + target.x * this.layout.tileSize, - this.layout.gridY + target.y * this.layout.tileSize, + this.tileTopLeftX(target.x), + this.tileTopLeftY(target.y), this.layout.tileSize, this.layout.tileSize, usable.effect === 'heal' ? 0x45b875 : 0x4f86d9, 0.3 ); + marker.setData('tileX', target.x); + marker.setData('tileY', target.y); marker.setOrigin(0); marker.setStrokeStyle(2, usable.effect === 'heal' ? 0xb6ffd2 : 0xd9e8ff, 0.9); marker.setDepth(9); + if (this.mapMask) { + marker.setMask(this.mapMask); + } + marker.setVisible(this.isTileVisible(target.x, target.y)); marker.setInteractive({ useHandCursor: true }); marker.on('pointerover', () => { marker.setFillStyle(usable.effect === 'heal' ? 0x45b875 : 0x4f86d9, 0.44); @@ -1703,6 +2012,7 @@ export class BattleScene extends Phaser.Scene { } else { this.syncUnitMotion(defender); } + this.updateMiniMap(); return { ...preview, @@ -1756,6 +2066,7 @@ export class BattleScene extends Phaser.Scene { target.hp = Math.min(target.maxHp, target.hp + healAmount); this.syncUnitMotion(target); this.flashSupport(target, `+${healAmount}`, '#a8ffd0'); + this.updateMiniMap(); } if (usable.effect === 'focus') { @@ -1989,15 +2300,14 @@ export class BattleScene extends Phaser.Scene { } private pointerToTile(pointer: Phaser.Input.Pointer) { - const { gridX, gridY, gridSize, tileSize } = this.layout; - if (pointer.x < gridX || pointer.y < gridY || pointer.x >= gridX + gridSize || pointer.y >= gridY + gridSize) { + const { gridX, gridY, gridWidth, gridHeight, tileSize } = this.layout; + if (pointer.x < gridX || pointer.y < gridY || pointer.x >= gridX + gridWidth || pointer.y >= gridY + gridHeight) { return undefined; } - return { - x: Math.floor((pointer.x - gridX) / tileSize), - y: Math.floor((pointer.y - gridY) / tileSize) - }; + const x = this.cameraTileX + Math.floor((pointer.x - gridX) / tileSize); + const y = this.cameraTileY + Math.floor((pointer.y - gridY) / tileSize); + return this.isInBounds(x, y) ? { x, y } : undefined; } private showMapMenu(pointerX: number, pointerY: number) { @@ -2223,6 +2533,12 @@ export class BattleScene extends Phaser.Scene { this.restoreUnitView(unit, savedUnit.direction); }); + const cameraFocus = firstBattleUnits.find((unit) => unit.faction === 'ally' && unit.hp > 0) ?? firstBattleUnits[0]; + if (cameraFocus) { + this.centerCameraOnTile(cameraFocus.x, cameraFocus.y); + } else { + this.updateCameraView(); + } this.resetActedStyles(); this.updateTurnText(); this.renderRosterPanel(this.rosterTab, '저장된 전투 상태입니다.'); @@ -2292,7 +2608,8 @@ export class BattleScene extends Phaser.Scene { this.activeFaction = 'enemy'; this.updateTurnText(); - this.renderSituationPanel('아군 턴을 종료했습니다.\n적군이 행동을 시작합니다.'); + const recoveryMessage = this.applyTerrainRecovery('enemy'); + this.renderSituationPanel(['아군 턴을 종료했습니다.', recoveryMessage, '적군이 행동을 시작합니다.'].filter(Boolean).join('\n')); void this.runEnemyTurn(); } @@ -2331,6 +2648,9 @@ export class BattleScene extends Phaser.Scene { } private async executeEnemyAction(enemy: UnitData) { + this.centerCameraOnTile(enemy.x, enemy.y); + await this.delay(120); + const behavior = this.enemyBehavior(enemy); let target = this.chooseEnemyTarget(enemy, behavior); @@ -2343,12 +2663,14 @@ export class BattleScene extends Phaser.Scene { if (destination && (destination.x !== enemy.x || destination.y !== enemy.y)) { enemy.x = destination.x; enemy.y = destination.y; + this.centerCameraOnTile(destination.x, destination.y); await this.moveUnitViewAsync(enemy, destination.x, destination.y, this.unitViews.get(enemy.id), 260); } } target = this.chooseTargetInRange(enemy) ?? target; if (this.canAttack(enemy, target)) { + this.centerCameraOnTile(Math.floor((enemy.x + target.x) / 2), Math.floor((enemy.y + target.y) / 2)); const result = this.resolveAttack(enemy, target); await this.playCombatCutIn(result); result.counter = this.resolveCounterAttack(result); @@ -2949,6 +3271,7 @@ export class BattleScene extends Phaser.Scene { ease: 'Sine.easeInOut', onComplete: () => { this.syncUnitMotion(unit, view, direction); + this.updateMiniMap(); resolve(); } }); @@ -2972,9 +3295,90 @@ export class BattleScene extends Phaser.Scene { this.activeFaction = 'ally'; this.actedUnitIds.clear(); this.attackIntents = []; + const recoveryMessage = this.applyTerrainRecovery('ally'); this.resetActedStyles(); this.updateTurnText(); - this.renderRosterPanel('ally', `${this.turnNumber}턴 아군 차례입니다.\n행동할 장수를 선택하세요.`); + this.renderRosterPanel('ally', [`${this.turnNumber}턴 아군 차례입니다.`, recoveryMessage, '행동할 장수를 선택하세요.'].filter(Boolean).join('\n')); + } + + private applyTerrainRecovery(faction: ActiveFaction) { + const messages: string[] = []; + + firstBattleUnits + .filter((unit) => unit.faction === faction && unit.hp > 0) + .forEach((unit) => { + const terrain = firstBattleMap.terrain[unit.y][unit.x]; + const terrainRule = getTerrainRule(terrain); + const hpGain = Math.min(terrainRule.recoveryHp ?? 0, unit.maxHp - unit.hp); + const moraleBonus = terrainRule.moraleBonus ?? 0; + if (hpGain <= 0 && moraleBonus <= 0) { + return; + } + + if (hpGain > 0) { + unit.hp += hpGain; + } + if (moraleBonus > 0) { + this.applyTerrainMorale(unit, moraleBonus); + } + + const parts = [ + hpGain > 0 ? `병력 +${hpGain}` : '', + moraleBonus > 0 ? `사기 +${moraleBonus}` : '' + ].filter(Boolean); + messages.push(`${unit.name} ${terrainRule.label}: ${parts.join(' / ')}`); + this.showTerrainRecoveryPopup(unit, parts.join(' ')); + }); + + if (messages.length === 0) { + return undefined; + } + + soundDirector.playEffect('exp-gain', { volume: 0.18, stopAfterMs: 520 }); + const message = `거점 효과\n${messages.slice(0, 3).join('\n')}${messages.length > 3 ? `\n외 ${messages.length - 3}명` : ''}`; + this.pushBattleLog(message); + return message; + } + + private applyTerrainMorale(unit: UnitData, moraleBonus: number) { + const existing = this.battleBuffs.get(unit.id); + this.battleBuffs.set(unit.id, { + unitId: unit.id, + label: existing ? `${existing.label} / 거점 사기` : '거점 사기', + turns: Math.max(existing?.turns ?? 0, 1), + attackBonus: existing?.attackBonus ?? 0, + hitBonus: Math.max(existing?.hitBonus ?? 0, moraleBonus * 2), + criticalBonus: Math.max(existing?.criticalBonus ?? 0, moraleBonus) + }); + } + + private showTerrainRecoveryPopup(unit: UnitData, label: string) { + const view = this.unitViews.get(unit.id); + if (!view || !this.isTileVisible(unit.x, unit.y)) { + return; + } + + const popup = this.add.text(view.sprite.x, view.sprite.y - this.layout.tileSize * 0.56, label, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '14px', + color: '#a8ffd0', + fontStyle: '700', + stroke: '#082416', + strokeThickness: 4 + }); + popup.setOrigin(0.5); + popup.setDepth(31); + if (this.mapMask) { + popup.setMask(this.mapMask); + } + this.tweens.add({ + targets: popup, + y: popup.y - 22, + alpha: 0, + duration: 900, + ease: 'Sine.easeOut', + onComplete: () => popup.destroy() + }); } private updateTurnText() { @@ -3234,6 +3638,7 @@ export class BattleScene extends Phaser.Scene { this.selectedUnit = unit; this.pendingMove = undefined; this.hideCommandMenu(); + this.updateMiniMap(); soundDirector.playSelect(); this.moveUnitView(unit, fromX, fromY, view, 180); @@ -3269,7 +3674,10 @@ export class BattleScene extends Phaser.Scene { y: targetY, duration, ease: 'Sine.easeInOut', - onComplete: () => this.syncUnitMotion(unit, view, direction) + onComplete: () => { + this.syncUnitMotion(unit, view, direction); + this.updateMiniMap(); + } }); this.tweens.add({ targets: view.label, @@ -3675,17 +4083,23 @@ export class BattleScene extends Phaser.Scene { this.renderSituationLine('좌표', `${x + 1}, ${y + 1}`, left, top + 48, width); this.renderSituationLine('지형', terrainDisplayLabels[terrain], left, top + 92, width); - this.renderSituationLine('이동 비용', `${terrainRule.moveCost}`, left, top + 136, width); + this.renderSituationLine('이동 비용', terrainRule.passable === false ? '진입 불가' : `${terrainRule.moveCost}`, left, top + 136, width); this.renderSituationLine('방어 보정', this.formatSignedValue(terrainRule.defenseBonus), left, top + 180, width); this.renderSituationLine('공격 방어', this.formatSignedValue(attackDefense), left, top + 224, width); this.renderSituationLine('책략 저항', this.formatSignedValue(strategyDefense), left, top + 268, width); this.renderSituationLine('도구 피해', this.formatSignedValue(itemDefense), left, top + 312, width); + const recovery = + terrainRule.recoveryHp || terrainRule.moraleBonus + ? `\n턴 시작 시 병력 +${terrainRule.recoveryHp ?? 0}, 사기 +${terrainRule.moraleBonus ?? 0} 효과를 받습니다.` + : ''; const message = - terrainRule.defenseBonus > 0 + terrainRule.passable === false + ? '이 지형은 이동할 수 없습니다. 길목과 다리, 완만한 평지를 찾아 우회해야 합니다.' + : terrainRule.defenseBonus > 0 ? '이 지형에 있는 부대는 방어 보정으로 받는 피해가 줄어듭니다.' : '이 지형은 기본 방어 보정이 없습니다.'; - this.renderPanelMessage(message, left, top + 370, width, 66); + this.renderPanelMessage(`${message}${recovery}`, left, top + 370, width, 76); } private formatSignedValue(value: number) { @@ -4096,6 +4510,14 @@ export class BattleScene extends Phaser.Scene { phase: this.phase, battleOutcome: this.battleOutcome ?? null, resultVisible: this.resultObjects.length > 0, + camera: { + x: this.cameraTileX, + y: this.cameraTileY, + visibleColumns: this.layout.visibleColumns, + visibleRows: this.layout.visibleRows, + mapWidth: firstBattleMap.width, + mapHeight: firstBattleMap.height + }, selectedUnitId: this.selectedUnit?.id ?? null, pendingMove: this.pendingMove ? { @@ -4149,12 +4571,14 @@ export class BattleScene extends Phaser.Scene { unit.hp = 0; this.applyDefeatedStyle(unit); }); + this.updateMiniMap(); } else { const liuBei = firstBattleUnits.find((unit) => unit.id === 'liu-bei'); if (liuBei) { liuBei.hp = 0; this.applyDefeatedStyle(liuBei); } + this.updateMiniMap(); } this.completeBattle(outcome);