Expand first battle map and camera controls

This commit is contained in:
2026-06-22 14:56:22 +09:00
parent 6509d2a682
commit 3538a3aaaa
4 changed files with 717 additions and 92 deletions

View File

@@ -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<TerrainType, TerrainRule> = {
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<UnitClassKey, UnitClassDefinition> = {
@@ -44,7 +49,9 @@ export const unitClasses: Record<UnitClassKey, UnitClassDefinition> = {
hill: 95,
village: 110,
fort: 115,
camp: 115
camp: 115,
river: 0,
cliff: 0
}
},
infantry: {
@@ -60,7 +67,9 @@ export const unitClasses: Record<UnitClassKey, UnitClassDefinition> = {
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<UnitClassKey, UnitClassDefinition> = {
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<UnitClassKey, UnitClassDefinition> = {
hill: 105,
village: 105,
fort: 110,
camp: 110
camp: 110,
river: 0,
cliff: 0
}
},
archer: {
@@ -119,7 +132,9 @@ export const unitClasses: Record<UnitClassKey, UnitClassDefinition> = {
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<UnitClassKey, UnitClassDefinition> = {
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<UnitClassKey, UnitClassDefinition> = {
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<UnitClassKey, UnitClassDefinition> = {
hill: 110,
village: 105,
fort: 105,
camp: 110
camp: 110,
river: 0,
cliff: 0
},
movementCosts: {
forest: 1,

View File

@@ -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
}
];

View File

@@ -25,6 +25,13 @@ const unitTexture: Record<string, string> = {
'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<string, EnemyAiBehavior> = {
'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<TerrainType, string> = {
hill: '언덕',
village: '마을',
fort: '요새',
camp: '진영'
camp: '진영',
river: '강',
cliff: '절벽'
};
const mapMenuLabels: Partial<Record<MapMenuAction, string>> = {
@@ -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<string, Phaser.GameObjects.Rectangle>();
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);