From f99884ea290e594f13d53350deeb8f98e1721ebe Mon Sep 17 00:00:00 2001 From: Wickedness Date: Mon, 22 Jun 2026 02:12:57 +0900 Subject: [PATCH] Add class and terrain battle rules --- docs/roadmap.md | 2 +- src/game/data/battleRules.ts | 195 +++++++++++++++++++++++++++++++++ src/game/data/scenario.ts | 44 +++++--- src/game/scenes/BattleScene.ts | 156 +++++++++++++++----------- 4 files changed, 314 insertions(+), 83 deletions(-) create mode 100644 src/game/data/battleRules.ts diff --git a/docs/roadmap.md b/docs/roadmap.md index 6ff315e..7975df0 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -16,7 +16,7 @@ Build a small complete tactical RPG loop: - Cinematic title scene with an original Taoyuan Oath background, subtle motion, menu UI, and first-input audio - Cinematic prologue pages with high-resolution story backgrounds, character portraits, non-black scene transitions, and scenario data - File-based original soft stereo orchestral BGM loops for title, prologue, oath, militia, and battle-prep scenes -- First battle scene with a high-resolution battlefield background, large 12x12 tactical grid, high-detail pixel-style unit sprites, ally/enemy roster tabs, unit detail stat panels, movement range preview, click-to-move unit movement, cursor-adjacent post-move command popup, right-click move cancel, right-click tactical menu, turn ending/reset, resonance bond tracking, and grayscale acted-unit feedback +- First battle scene with a high-resolution battlefield background, large 12x12 tactical grid, high-detail pixel-style unit sprites, class and terrain affinity rules, ally/enemy roster tabs, unit detail stat panels, terrain-cost movement range preview, click-to-move unit movement, cursor-adjacent post-move command popup, right-click move cancel, right-click tactical menu, turn ending/reset, resonance bond tracking, and grayscale acted-unit feedback - Flow verification script from title to first battle ## Next Steps diff --git a/src/game/data/battleRules.ts b/src/game/data/battleRules.ts new file mode 100644 index 0000000..a9bd196 --- /dev/null +++ b/src/game/data/battleRules.ts @@ -0,0 +1,195 @@ +export type TerrainType = 'plain' | 'road' | 'forest' | 'hill' | 'village' | 'fort' | 'camp'; + +export type UnitClassKey = 'lord' | 'infantry' | 'cavalry' | 'spearman' | 'archer' | 'bandit' | 'yellowTurban' | 'rebelLeader'; + +export type TerrainRule = { + label: string; + color: number; + alpha: number; + moveCost: number; + defenseBonus: number; +}; + +export type UnitClassDefinition = { + key: UnitClassKey; + name: string; + family: string; + role: string; + description: string; + terrainRatings: Record; + movementCosts?: Partial>; +}; + +export const terrainRules: Record = { + plain: { label: '평지', color: 0x6d8056, alpha: 0.04, moveCost: 1, defenseBonus: 0 }, + 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 } +}; + +export const unitClasses: Record = { + lord: { + key: 'lord', + name: '군웅', + family: '군주계', + role: '지휘 / 균형', + description: '지형을 크게 타지 않고 아군 전선을 안정시키는 지휘관 병과.', + terrainRatings: { + plain: 100, + road: 105, + forest: 95, + hill: 95, + village: 110, + fort: 115, + camp: 115 + } + }, + infantry: { + key: 'infantry', + name: '의용 보병', + family: '보병계', + role: '전열 / 방어', + description: '숲과 진영에서 버티는 힘이 좋고 기병의 돌격을 받아내기 좋다.', + terrainRatings: { + plain: 100, + road: 100, + forest: 110, + hill: 105, + village: 115, + fort: 120, + camp: 115 + }, + movementCosts: { + forest: 1, + village: 1, + fort: 1, + camp: 1 + } + }, + cavalry: { + key: 'cavalry', + name: '경기병', + family: '기병계', + role: '기동 / 돌파', + description: '길과 평지에서 빠르게 압박하지만 숲과 언덕에서는 힘이 떨어진다.', + terrainRatings: { + plain: 110, + road: 120, + forest: 75, + hill: 80, + village: 95, + fort: 90, + camp: 100 + }, + movementCosts: { + road: 1, + forest: 3, + hill: 3 + } + }, + spearman: { + key: 'spearman', + name: '창병', + family: '창병계', + role: '대기병 / 압박', + description: '기병 견제와 전선 고정에 특화된 근접 병과.', + terrainRatings: { + plain: 105, + road: 100, + forest: 95, + hill: 105, + village: 105, + fort: 110, + camp: 110 + } + }, + archer: { + key: 'archer', + name: '궁병', + family: '궁병계', + role: '원거리 / 견제', + description: '언덕과 요새에서 유리하고 근접 압박에는 약한 원거리 병과.', + terrainRatings: { + plain: 95, + road: 95, + forest: 90, + hill: 115, + village: 100, + fort: 120, + camp: 105 + }, + movementCosts: { + hill: 1 + } + }, + bandit: { + key: 'bandit', + name: '도적', + family: '적병계', + role: '습격 / 산악', + description: '숲과 언덕에서 강하고 정규전 방어 지형에서는 약하다.', + terrainRatings: { + plain: 95, + road: 95, + forest: 115, + hill: 115, + village: 100, + fort: 90, + camp: 95 + }, + movementCosts: { + forest: 1, + hill: 1 + } + }, + yellowTurban: { + key: 'yellowTurban', + name: '황건병', + family: '황건적계', + role: '민병 / 물량', + description: '능력은 낮지만 험지에서 성가시게 버티는 반란군 병과.', + terrainRatings: { + plain: 95, + road: 95, + forest: 105, + hill: 105, + village: 100, + fort: 90, + camp: 95 + }, + movementCosts: { + forest: 1 + } + }, + rebelLeader: { + key: 'rebelLeader', + name: '두령', + family: '두령계', + role: '지휘 / 근접', + description: '황건적을 이끄는 지휘관형 적병. 진영과 험지에서 버티는 힘이 있다.', + terrainRatings: { + plain: 100, + road: 100, + forest: 110, + hill: 110, + village: 105, + fort: 105, + camp: 110 + }, + movementCosts: { + forest: 1, + hill: 1 + } + } +}; + +export function getUnitClass(classKey: UnitClassKey) { + return unitClasses[classKey]; +} + +export function getTerrainRule(terrain: TerrainType) { + return terrainRules[terrain]; +} diff --git a/src/game/data/scenario.ts b/src/game/data/scenario.ts index 6333641..fe164b1 100644 --- a/src/game/data/scenario.ts +++ b/src/game/data/scenario.ts @@ -1,3 +1,5 @@ +import type { TerrainType, UnitClassKey } from './battleRules'; + export type PortraitKey = 'liuBei' | 'guanYu' | 'zhangFei'; export type StoryPage = { @@ -15,6 +17,7 @@ export type UnitData = { name: string; faction: 'ally' | 'enemy'; className: string; + classKey: UnitClassKey; hp: number; maxHp: number; attack: number; @@ -41,8 +44,6 @@ export type BattleBond = { description: string; }; -export type TerrainType = 'plain' | 'forest' | 'hill' | 'fort'; - export type BattleMap = { width: number; height: number; @@ -140,18 +141,18 @@ export const firstBattleMap: BattleMap = { width: 12, height: 12, terrain: [ - ['plain', 'plain', 'forest', 'forest', 'plain', 'plain', 'plain', 'hill', 'hill', 'plain', 'plain', 'plain'], - ['plain', 'forest', 'forest', 'plain', 'plain', 'plain', 'plain', 'plain', 'hill', 'plain', 'plain', 'plain'], - ['plain', 'forest', 'plain', 'plain', 'plain', 'plain', 'forest', 'plain', 'plain', 'plain', 'plain', 'plain'], - ['plain', 'plain', 'plain', 'hill', 'hill', 'plain', 'forest', 'forest', 'plain', 'plain', 'plain', 'plain'], - ['plain', 'plain', 'plain', 'hill', 'plain', 'plain', 'plain', 'forest', 'plain', 'plain', 'plain', 'plain'], - ['plain', 'plain', 'plain', 'plain', 'plain', 'fort', 'plain', 'plain', 'plain', 'plain', 'forest', 'forest'], - ['plain', 'plain', 'plain', 'plain', 'plain', 'plain', 'plain', 'plain', 'plain', 'plain', 'forest', 'plain'], - ['forest', 'forest', 'plain', 'plain', 'plain', 'plain', 'plain', 'hill', 'hill', 'plain', 'plain', 'plain'], - ['forest', 'plain', 'plain', 'plain', 'plain', 'plain', 'plain', 'hill', 'plain', 'plain', 'plain', 'plain'], - ['plain', 'plain', 'plain', 'forest', 'forest', 'plain', 'plain', 'plain', 'plain', 'plain', 'plain', 'plain'], - ['plain', 'plain', 'plain', 'plain', 'forest', 'plain', 'plain', 'plain', 'plain', 'forest', 'forest', 'plain'], - ['plain', 'plain', 'plain', 'plain', 'plain', 'plain', 'plain', 'plain', 'plain', 'plain', 'forest', 'plain'] + ['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'] ] }; @@ -160,7 +161,8 @@ export const firstBattleUnits: UnitData[] = [ id: 'liu-bei', name: '유비', faction: 'ally', - className: '의병장', + className: '군웅', + classKey: 'lord', hp: 32, maxHp: 32, attack: 9, @@ -173,7 +175,8 @@ export const firstBattleUnits: UnitData[] = [ id: 'guan-yu', name: '관우', faction: 'ally', - className: '보병', + className: '의용 보병', + classKey: 'infantry', hp: 30, maxHp: 30, attack: 10, @@ -187,6 +190,7 @@ export const firstBattleUnits: UnitData[] = [ name: '장비', faction: 'ally', className: '창병', + classKey: 'spearman', hp: 30, maxHp: 30, attack: 10, @@ -199,7 +203,8 @@ export const firstBattleUnits: UnitData[] = [ id: 'rebel-a', name: '황건병', faction: 'enemy', - className: '도적', + className: '황건병', + classKey: 'yellowTurban', hp: 20, maxHp: 20, attack: 6, @@ -212,7 +217,8 @@ export const firstBattleUnits: UnitData[] = [ id: 'rebel-b', name: '황건병', faction: 'enemy', - className: '도적', + className: '황건병', + classKey: 'yellowTurban', hp: 20, maxHp: 20, attack: 6, @@ -226,6 +232,7 @@ export const firstBattleUnits: UnitData[] = [ name: '황건궁병', faction: 'enemy', className: '궁병', + classKey: 'archer', hp: 18, maxHp: 18, attack: 7, @@ -239,6 +246,7 @@ export const firstBattleUnits: UnitData[] = [ name: '두령 한석', faction: 'enemy', className: '두령', + classKey: 'rebelLeader', hp: 30, maxHp: 30, attack: 9, diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index b5399ee..920da7c 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -5,19 +5,12 @@ import { firstBattleMap, firstBattleUnits, type BattleBond, - type TerrainType, type UnitData, type UnitStats } from '../data/scenario'; +import { getTerrainRule, getUnitClass, type TerrainType } from '../data/battleRules'; import { palette } from '../ui/palette'; -const terrainColor: Record = { - plain: 0x6d8056, - forest: 0x355f42, - hill: 0x82724e, - fort: 0x8b6f48 -}; - const unitTexture: Record = { 'liu-bei': 'unit-liu-bei', 'guan-yu': 'unit-guan-yu', @@ -208,13 +201,14 @@ export class BattleScene extends Phaser.Scene { 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, - terrainColor[terrain], - this.terrainAlpha(terrain) + terrainRule.color, + terrainRule.alpha ); tile.setOrigin(0); tile.setStrokeStyle(1, 0x0a1014, 0.5); @@ -328,34 +322,28 @@ export class BattleScene extends Phaser.Scene { } private showMoveRange(unit: UnitData) { - for (let y = 0; y < firstBattleMap.height; y += 1) { - for (let x = 0; x < firstBattleMap.width; x += 1) { - if (!this.canMoveTo(unit, x, y)) { - continue; + 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.layout.tileSize, + this.layout.tileSize, + palette.blue, + 0.24 + ); + marker.setOrigin(0); + marker.setStrokeStyle(1, palette.blue, 0.68); + marker.setDepth(4); + marker.setInteractive({ useHandCursor: true }); + marker.on('pointerover', () => marker.setFillStyle(palette.blue, 0.36)); + marker.on('pointerout', () => marker.setFillStyle(palette.blue, 0.24)); + marker.on('pointerdown', (pointer: Phaser.Input.Pointer) => { + if (pointer.leftButtonDown()) { + this.moveSelectedUnit(x, y, pointer); } - - const marker = this.add.rectangle( - this.layout.gridX + x * this.layout.tileSize, - this.layout.gridY + y * this.layout.tileSize, - this.layout.tileSize, - this.layout.tileSize, - palette.blue, - 0.24 - ); - marker.setOrigin(0); - marker.setStrokeStyle(1, palette.blue, 0.68); - marker.setDepth(4); - marker.setInteractive({ useHandCursor: true }); - marker.on('pointerover', () => marker.setFillStyle(palette.blue, 0.36)); - marker.on('pointerout', () => marker.setFillStyle(palette.blue, 0.24)); - marker.on('pointerdown', (pointer: Phaser.Input.Pointer) => { - if (pointer.leftButtonDown()) { - this.moveSelectedUnit(x, y, pointer); - } - }); - this.markers.push(marker); - } - } + }); + this.markers.push(marker); + }); } private moveSelectedUnit(x: number, y: number, pointer?: Phaser.Input.Pointer) { @@ -386,35 +374,69 @@ export class BattleScene extends Phaser.Scene { } private canMoveTo(unit: UnitData, x: number, y: number) { - const distance = Math.abs(unit.x - x) + Math.abs(unit.y - y); - return ( - distance > 0 && - distance <= unit.move && - x >= 0 && - y >= 0 && - x < firstBattleMap.width && - y < firstBattleMap.height && - !this.isOccupied(x, y, unit.id) - ); + return this.reachableTiles(unit).some((tile) => tile.x === x && tile.y === y); + } + + private reachableTiles(unit: UnitData) { + const directions = [ + { x: 1, y: 0 }, + { x: -1, y: 0 }, + { x: 0, y: 1 }, + { x: 0, y: -1 } + ]; + const costs = new Map([[this.tileKey(unit.x, unit.y), 0]]); + const queue = [{ x: unit.x, y: unit.y, cost: 0 }]; + const reachable = new Map(); + + while (queue.length > 0) { + queue.sort((a, b) => a.cost - b.cost); + const current = queue.shift(); + if (!current) { + break; + } + + directions.forEach((direction) => { + const nextX = current.x + direction.x; + const nextY = current.y + direction.y; + if (!this.isInBounds(nextX, nextY) || this.isOccupied(nextX, nextY, unit.id)) { + return; + } + + const terrain = firstBattleMap.terrain[nextY][nextX]; + const stepCost = this.movementCost(unit, terrain); + const nextCost = current.cost + stepCost; + const key = this.tileKey(nextX, nextY); + if (nextCost > unit.move || nextCost >= (costs.get(key) ?? Number.POSITIVE_INFINITY)) { + return; + } + + costs.set(key, nextCost); + const tile = { x: nextX, y: nextY, cost: nextCost }; + reachable.set(key, tile); + queue.push(tile); + }); + } + + return Array.from(reachable.values()); + } + + private movementCost(unit: UnitData, terrain: TerrainType) { + const unitClass = getUnitClass(unit.classKey); + return unitClass.movementCosts?.[terrain] ?? getTerrainRule(terrain).moveCost; + } + + private isInBounds(x: number, y: number) { + return x >= 0 && y >= 0 && x < firstBattleMap.width && y < firstBattleMap.height; + } + + private tileKey(x: number, y: number) { + return `${x},${y}`; } private isOccupied(x: number, y: number, exceptUnitId?: string) { return firstBattleUnits.some((unit) => unit.id !== exceptUnitId && unit.x === x && unit.y === y); } - private terrainAlpha(terrain: TerrainType) { - if (terrain === 'forest') { - return 0.16; - } - if (terrain === 'hill') { - return 0.13; - } - if (terrain === 'fort') { - return 0.18; - } - return 0.03; - } - private tileCenterX(x: number) { return this.layout.gridX + x * this.layout.tileSize + this.layout.tileSize / 2; } @@ -1028,6 +1050,7 @@ export class BattleScene extends Phaser.Scene { private renderRosterRow(unit: UnitData, x: number, y: number, width: number) { const acted = this.actedUnitIds.has(unit.id); const active = this.selectedUnit?.id === unit.id; + const unitClass = getUnitClass(unit.classKey); const rowBg = this.trackSideObject(this.add.rectangle(x, y, width, 42, active ? 0x2f4050 : 0x101820, acted ? 0.54 : 0.9)); rowBg.setOrigin(0); rowBg.setStrokeStyle(1, active ? palette.gold : unit.faction === 'ally' ? palette.blue : 0xb86b55, active ? 0.88 : 0.52); @@ -1041,7 +1064,8 @@ export class BattleScene extends Phaser.Scene { color: nameColor, fontStyle: '700' })); - this.trackSideObject(this.add.text(x + 88, y + 9, unit.className, { + this.trackSideObject(this.add.text(x + 88, y + 9, unitClass.name, + { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '14px', color: acted ? '#8c8c8c' : '#9fb0bf' @@ -1076,18 +1100,22 @@ export class BattleScene extends Phaser.Scene { const top = panelY + 124; const acted = this.actedUnitIds.has(unit.id); const factionColor = unit.faction === 'ally' ? palette.blue : 0xb86b55; + const unitClass = getUnitClass(unit.classKey); + const terrain = firstBattleMap.terrain[unit.y][unit.x]; + const terrainRule = getTerrainRule(terrain); + const terrainRating = unitClass.terrainRatings[terrain]; const header = this.trackSideObject(this.add.rectangle(left, top, width, 60, 0x101820, 0.94)); header.setOrigin(0); header.setStrokeStyle(1, factionColor, 0.76); - this.trackSideObject(this.add.text(left + 14, top + 9, rosterLabels[unit.faction], { + this.trackSideObject(this.add.text(left + 14, top + 9, `${rosterLabels[unit.faction]} / ${unitClass.family}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '14px', color: unit.faction === 'ally' ? '#9fd0ff' : '#ffb2a4', fontStyle: '700' })); - this.trackSideObject(this.add.text(left + 14, top + 29, `${unit.name} ${unit.className}`, { + this.trackSideObject(this.add.text(left + 14, top + 29, `${unit.name} ${unitClass.name}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '22px', color: acted ? '#bdbdbd' : '#f2e3bf', @@ -1133,7 +1161,7 @@ export class BattleScene extends Phaser.Scene { this.renderStatRow(stat.label, unit.stats[stat.key], left, statTop + index * 32, width); }); - const positionText = `위치 ${unit.x + 1}, ${unit.y + 1}${acted ? ' / 행동완료' : ''}`; + const positionText = `위치 ${unit.x + 1}, ${unit.y + 1} / ${terrainRule.label} ${terrainRating}%${acted ? ' / 행동완료' : ''}`; this.trackSideObject(this.add.text(left, top + 328, positionText, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '15px',