Expand first battle map and camera controls
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user