Compare commits
5 Commits
f7fae92fca
...
a48f4886d7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a48f4886d7 | ||
|
|
1d855b64a2 | ||
|
|
12f195bed7 | ||
|
|
a1d22369cb | ||
|
|
d23dcd1e6f |
@@ -61,7 +61,7 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
|
||||
super.initState();
|
||||
const config = PqConfig();
|
||||
final mutations = GameMutations(config);
|
||||
final rewards = RewardService(mutations);
|
||||
final rewards = RewardService(mutations, config);
|
||||
|
||||
_controller = GameSessionController(
|
||||
progressService: ProgressService(
|
||||
|
||||
@@ -65,9 +65,9 @@ class AudioService {
|
||||
SfxChannelPool? _playerSfxPool;
|
||||
SfxChannelPool? _monsterSfxPool;
|
||||
|
||||
// 채널별 풀 크기 (줄임: 동시 재생 문제 완화)
|
||||
static const int _playerPoolSize = 2;
|
||||
static const int _monsterPoolSize = 2;
|
||||
// 채널별 풀 크기 (배속 전투에서 사운드 누락 방지)
|
||||
static const int _playerPoolSize = 4;
|
||||
static const int _monsterPoolSize = 4;
|
||||
|
||||
// 현재 볼륨
|
||||
double _bgmVolume = 0.7;
|
||||
|
||||
@@ -98,9 +98,9 @@ class CombatCalculator {
|
||||
final isParried = parryRoll < defenderParryRate;
|
||||
|
||||
// 3. 기본 데미지 계산 (0.8 ~ 1.2 변동)
|
||||
// DEF 감산 비율: 0.3 (기존 0.5에서 축소하여 의미있는 피해 보장)
|
||||
// DEF 감산 비율: 0.5 (방어력 효과 상향, 몬스터 ATK 하향과 연동)
|
||||
final damageVariation = 0.8 + rng.nextDouble() * 0.4;
|
||||
var baseDamage = (attackerAtk * damageVariation - defenderDef * 0.3);
|
||||
var baseDamage = (attackerAtk * damageVariation - defenderDef * 0.5);
|
||||
|
||||
// 4. 크리티컬 판정
|
||||
final criRoll = rng.nextDouble();
|
||||
|
||||
@@ -135,30 +135,36 @@ class ItemService {
|
||||
}
|
||||
|
||||
/// 방패 스탯 생성
|
||||
///
|
||||
/// DEF 배율 조정 (v2): 방패 DEF를 0.15배로 축소
|
||||
ItemStats _generateShieldStats(int baseValue, ItemRarity rarity) {
|
||||
final blockBonus = 0.05 + rarity.index * 0.02;
|
||||
final def = (baseValue * 0.15).round();
|
||||
|
||||
return ItemStats(def: baseValue ~/ 2, blockRate: blockBonus);
|
||||
return ItemStats(def: def, blockRate: blockBonus);
|
||||
}
|
||||
|
||||
/// 방어구 스탯 생성
|
||||
///
|
||||
/// DEF 배율 조정 (v2): 9개 방어구 합산 DEF ≈ 무기 ATK × 1.6
|
||||
/// 기존 배율(합계 8.0)에서 대폭 축소하여 일반 공격 데미지 정상화
|
||||
ItemStats _generateArmorStats(
|
||||
int baseValue,
|
||||
ItemRarity rarity,
|
||||
EquipmentSlot slot,
|
||||
) {
|
||||
// 슬롯별 방어력 가중치
|
||||
// 슬롯별 방어력 가중치 (총합 ~1.6으로 축소)
|
||||
final defMultiplier = switch (slot) {
|
||||
EquipmentSlot.hauberk => 1.5, // 갑옷류 최고
|
||||
EquipmentSlot.helm => 1.2,
|
||||
EquipmentSlot.gambeson => 1.0,
|
||||
EquipmentSlot.cuisses => 0.9,
|
||||
EquipmentSlot.greaves => 0.8,
|
||||
EquipmentSlot.brassairts => 0.7,
|
||||
EquipmentSlot.vambraces => 0.7,
|
||||
EquipmentSlot.gauntlets => 0.6,
|
||||
EquipmentSlot.sollerets => 0.6,
|
||||
_ => 0.5,
|
||||
EquipmentSlot.hauberk => 0.30, // 갑옷류 최고
|
||||
EquipmentSlot.helm => 0.25,
|
||||
EquipmentSlot.gambeson => 0.20,
|
||||
EquipmentSlot.cuisses => 0.18,
|
||||
EquipmentSlot.greaves => 0.16,
|
||||
EquipmentSlot.brassairts => 0.14,
|
||||
EquipmentSlot.vambraces => 0.14,
|
||||
EquipmentSlot.gauntlets => 0.12,
|
||||
EquipmentSlot.sollerets => 0.12,
|
||||
_ => 0.10,
|
||||
};
|
||||
|
||||
final def = (baseValue * defMultiplier).round();
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:asciineverdie/src/core/engine/combat_calculator.dart';
|
||||
import 'package:asciineverdie/src/core/engine/game_mutations.dart';
|
||||
import 'package:asciineverdie/src/core/engine/potion_service.dart';
|
||||
import 'package:asciineverdie/src/core/engine/reward_service.dart';
|
||||
import 'package:asciineverdie/src/core/engine/shop_service.dart';
|
||||
import 'package:asciineverdie/src/core/engine/skill_service.dart';
|
||||
import 'package:asciineverdie/src/core/model/combat_event.dart';
|
||||
import 'package:asciineverdie/src/core/model/combat_state.dart';
|
||||
@@ -13,6 +14,7 @@ import 'package:asciineverdie/src/core/model/combat_stats.dart';
|
||||
import 'package:asciineverdie/src/core/model/equipment_item.dart';
|
||||
import 'package:asciineverdie/src/core/model/equipment_slot.dart';
|
||||
import 'package:asciineverdie/src/core/model/game_state.dart';
|
||||
import 'package:asciineverdie/src/core/model/item_stats.dart';
|
||||
import 'package:asciineverdie/src/core/model/monster_combat_stats.dart';
|
||||
import 'package:asciineverdie/src/core/model/monster_grade.dart';
|
||||
import 'package:asciineverdie/src/core/model/potion.dart';
|
||||
@@ -514,12 +516,15 @@ class ProgressService {
|
||||
return (progress: progress, queue: queue);
|
||||
}
|
||||
|
||||
// 2. kill 태스크가 아니었고 heading도 아니면 heading 또는 buying 태스크 실행
|
||||
// (원본 670-677줄)
|
||||
if (oldTaskType != TaskType.kill && oldTaskType != TaskType.neutral) {
|
||||
// Gold가 충분하면 장비 구매 (원본 671-673줄)
|
||||
// 2. kill/heading/buying 태스크가 아니었으면 heading 또는 buying 태스크 실행
|
||||
// (원본 670-677줄) - buying 완료 후 무한 루프 방지
|
||||
if (oldTaskType != TaskType.kill &&
|
||||
oldTaskType != TaskType.neutral &&
|
||||
oldTaskType != TaskType.buying) {
|
||||
// Gold가 충분하면 장비 구매 (Common 장비 가격 기준)
|
||||
// 실제 구매 가격과 동일한 공식 사용: level * 50
|
||||
final gold = _getGold(state);
|
||||
final equipPrice = _equipPrice(state.traits.level);
|
||||
final equipPrice = state.traits.level * 50; // Common 장비 1개 가격
|
||||
if (gold > equipPrice) {
|
||||
final taskResult = pq_logic.startTask(
|
||||
progress,
|
||||
@@ -966,8 +971,12 @@ class ProgressService {
|
||||
|
||||
final nextLevel = state.traits.level + 1;
|
||||
final rng = state.rng;
|
||||
final hpGain = state.stats.con ~/ 3 + 1 + rng.nextInt(4);
|
||||
final mpGain = state.stats.intelligence ~/ 3 + 1 + rng.nextInt(4);
|
||||
|
||||
// HP/MP 증가량 (PlayerScaling 기반 + 랜덤 변동)
|
||||
// 기존: CON/3 + 1 + random(0-3) → ~6-9 HP/레벨 (너무 낮음)
|
||||
// 신규: 18 + CON/5 + random(0-4) → ~20-25 HP/레벨 (생존율 개선)
|
||||
final hpGain = 18 + state.stats.con ~/ 5 + rng.nextInt(5);
|
||||
final mpGain = 6 + state.stats.intelligence ~/ 5 + rng.nextInt(3);
|
||||
|
||||
var nextState = state.copyWith(
|
||||
traits: state.traits.copyWith(level: nextLevel),
|
||||
@@ -1078,29 +1087,48 @@ class ProgressService {
|
||||
return state.inventory.gold;
|
||||
}
|
||||
|
||||
/// 장비 가격 계산 (원본 Main.pas:612-616)
|
||||
/// Result := 5 * Level^2 + 10 * Level + 20
|
||||
int _equipPrice(int level) {
|
||||
return 5 * level * level + 10 * level + 20;
|
||||
}
|
||||
|
||||
/// 장비 구매 완료 처리 (원본 Main.pas:631-634)
|
||||
/// 장비 구매 완료 처리 (개선된 로직)
|
||||
///
|
||||
/// 1순위: 빈 슬롯에 Common 장비 최대한 채우기
|
||||
/// 2순위: 골드 남으면 물약 구매
|
||||
GameState _completeBuying(GameState state) {
|
||||
var nextState = state;
|
||||
final level = state.traits.level;
|
||||
final price = _equipPrice(level);
|
||||
final shopService = ShopService(rng: nextState.rng);
|
||||
|
||||
// Gold 차감 (inventory.gold 필드 사용)
|
||||
final newGold = math.max(0, state.inventory.gold - price);
|
||||
var nextState = state.copyWith(
|
||||
inventory: state.inventory.copyWith(gold: newGold),
|
||||
);
|
||||
// 1. 빈 슬롯 목록 수집
|
||||
final emptySlots = <int>[];
|
||||
for (var i = 0; i < Equipment.slotCount; i++) {
|
||||
if (nextState.equipment.getItemByIndex(i).isEmpty) {
|
||||
emptySlots.add(i);
|
||||
}
|
||||
}
|
||||
|
||||
// 장비 획득 (WinEquip)
|
||||
// 원본 Main.pas:797 - posn := Random(Equips.Items.Count); (11개 슬롯)
|
||||
final slotIndex = nextState.rng.nextInt(Equipment.slotCount);
|
||||
nextState = mutations.winEquipByIndex(nextState, level, slotIndex);
|
||||
// 2. 골드가 허용하는 한 빈 슬롯에 Common 장비 구매
|
||||
for (final slotIndex in emptySlots) {
|
||||
final slot = EquipmentSlot.values[slotIndex];
|
||||
final item = shopService.generateShopItem(
|
||||
playerLevel: level,
|
||||
slot: slot,
|
||||
targetRarity: ItemRarity.common,
|
||||
);
|
||||
final price = shopService.calculateBuyPrice(item);
|
||||
|
||||
// 물약 자동 구매 (남은 골드의 20% 사용)
|
||||
if (nextState.inventory.gold >= price) {
|
||||
nextState = nextState.copyWith(
|
||||
inventory: nextState.inventory.copyWith(
|
||||
gold: nextState.inventory.gold - price,
|
||||
),
|
||||
equipment: nextState.equipment
|
||||
.setItemByIndex(slotIndex, item)
|
||||
.copyWith(bestIndex: slotIndex),
|
||||
);
|
||||
} else {
|
||||
break; // 골드 부족 시 중단
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 물약 자동 구매 (남은 골드의 20% 사용)
|
||||
final potionService = const PotionService();
|
||||
final purchaseResult = potionService.autoPurchasePotions(
|
||||
playerLevel: level,
|
||||
@@ -1644,30 +1672,35 @@ class ProgressService {
|
||||
required String killerName,
|
||||
required DeathCause cause,
|
||||
}) {
|
||||
// 상실할 장비 개수 계산
|
||||
final lostCount = state.equipment.equippedItems.length;
|
||||
|
||||
// 사망 직전 전투 이벤트 저장 (최대 10개)
|
||||
final lastCombatEvents =
|
||||
state.progress.currentCombat?.recentEvents ?? const [];
|
||||
|
||||
// 빈 장비 생성 (기본 무기만 유지)
|
||||
final emptyEquipment = Equipment(
|
||||
items: [
|
||||
EquipmentItem.defaultWeapon(),
|
||||
EquipmentItem.empty(EquipmentSlot.shield),
|
||||
EquipmentItem.empty(EquipmentSlot.helm),
|
||||
EquipmentItem.empty(EquipmentSlot.hauberk),
|
||||
EquipmentItem.empty(EquipmentSlot.brassairts),
|
||||
EquipmentItem.empty(EquipmentSlot.vambraces),
|
||||
EquipmentItem.empty(EquipmentSlot.gauntlets),
|
||||
EquipmentItem.empty(EquipmentSlot.gambeson),
|
||||
EquipmentItem.empty(EquipmentSlot.cuisses),
|
||||
EquipmentItem.empty(EquipmentSlot.greaves),
|
||||
EquipmentItem.empty(EquipmentSlot.sollerets),
|
||||
],
|
||||
bestIndex: 0,
|
||||
);
|
||||
// 무기(슬롯 0)를 제외한 장착된 장비 중 1개를 제물로 삭제
|
||||
// 장착된 비무기 슬롯 인덱스 수집 (슬롯 1~10 중 장비가 있는 것)
|
||||
final equippedNonWeaponSlots = <int>[];
|
||||
for (var i = 1; i < Equipment.slotCount; i++) {
|
||||
if (state.equipment.getItemByIndex(i).isNotEmpty) {
|
||||
equippedNonWeaponSlots.add(i);
|
||||
}
|
||||
}
|
||||
|
||||
// 제물로 바칠 장비 선택 및 삭제
|
||||
var newEquipment = state.equipment;
|
||||
final lostCount = equippedNonWeaponSlots.isNotEmpty ? 1 : 0;
|
||||
|
||||
if (equippedNonWeaponSlots.isNotEmpty) {
|
||||
// 랜덤하게 1개 슬롯 선택
|
||||
final sacrificeIndex =
|
||||
equippedNonWeaponSlots[state.rng.nextInt(equippedNonWeaponSlots.length)];
|
||||
final slot = EquipmentSlot.values[sacrificeIndex];
|
||||
|
||||
// 해당 슬롯을 빈 장비로 교체
|
||||
newEquipment = newEquipment.setItemByIndex(
|
||||
sacrificeIndex,
|
||||
EquipmentItem.empty(slot),
|
||||
);
|
||||
}
|
||||
|
||||
// 사망 정보 생성 (전투 로그 포함)
|
||||
final deathInfo = DeathInfo(
|
||||
@@ -1689,7 +1722,7 @@ class ProgressService {
|
||||
);
|
||||
|
||||
return state.copyWith(
|
||||
equipment: emptyEquipment,
|
||||
equipment: newEquipment,
|
||||
progress: progress,
|
||||
deathInfo: deathInfo,
|
||||
);
|
||||
|
||||
@@ -1,25 +1,82 @@
|
||||
import 'package:asciineverdie/src/core/engine/game_mutations.dart';
|
||||
import 'package:asciineverdie/src/core/engine/item_service.dart';
|
||||
import 'package:asciineverdie/src/core/engine/shop_service.dart';
|
||||
import 'package:asciineverdie/src/core/model/equipment_slot.dart';
|
||||
import 'package:asciineverdie/src/core/model/game_state.dart';
|
||||
import 'package:asciineverdie/src/core/util/pq_logic.dart';
|
||||
import 'package:asciineverdie/src/core/model/pq_config.dart';
|
||||
import 'package:asciineverdie/src/core/util/pq_logic.dart' as pq_logic;
|
||||
|
||||
/// Applies quest/act rewards to the GameState using shared RNG.
|
||||
class RewardService {
|
||||
RewardService(this.mutations);
|
||||
RewardService(this.mutations, this.config);
|
||||
|
||||
final GameMutations mutations;
|
||||
final PqConfig config;
|
||||
|
||||
GameState applyReward(GameState state, RewardKind reward) {
|
||||
GameState applyReward(GameState state, pq_logic.RewardKind reward) {
|
||||
switch (reward) {
|
||||
case RewardKind.spell:
|
||||
case pq_logic.RewardKind.spell:
|
||||
return mutations.winSpell(state, state.stats.wis, state.traits.level);
|
||||
case RewardKind.equip:
|
||||
// 원본 Main.pas:797 - Random(Equips.Items.Count) (11개 슬롯)
|
||||
final slotIndex = state.rng.nextInt(Equipment.slotCount);
|
||||
return mutations.winEquipByIndex(state, state.traits.level, slotIndex);
|
||||
case RewardKind.stat:
|
||||
case pq_logic.RewardKind.equip:
|
||||
return _applyEquipReward(state);
|
||||
case pq_logic.RewardKind.stat:
|
||||
return mutations.winStat(state);
|
||||
case RewardKind.item:
|
||||
case pq_logic.RewardKind.item:
|
||||
return mutations.winItem(state);
|
||||
}
|
||||
}
|
||||
|
||||
/// 장비 보상 처리 (개선된 로직)
|
||||
///
|
||||
/// - 빈 슬롯: 무조건 장착
|
||||
/// - 기존 장비 있음: 점수 비교 → 업그레이드만 / 다운그레이드 시 판매
|
||||
GameState _applyEquipReward(GameState state) {
|
||||
final slotIndex = state.rng.nextInt(Equipment.slotCount);
|
||||
final slot = EquipmentSlot.values[slotIndex];
|
||||
final currentItem = state.equipment.getItemByIndex(slotIndex);
|
||||
final level = state.traits.level;
|
||||
|
||||
// 새 장비 생성
|
||||
final itemService = ItemService(rng: state.rng);
|
||||
final name = pq_logic.winEquip(config, state.rng, level, slotIndex);
|
||||
final newItem = itemService.generateEquipment(
|
||||
name: name,
|
||||
slot: slot,
|
||||
level: level,
|
||||
);
|
||||
|
||||
// 빈 슬롯이면 무조건 장착
|
||||
if (currentItem.isEmpty) {
|
||||
return state.copyWith(
|
||||
rng: state.rng,
|
||||
equipment: state.equipment
|
||||
.setItemByIndex(slotIndex, newItem)
|
||||
.copyWith(bestIndex: slotIndex),
|
||||
);
|
||||
}
|
||||
|
||||
// 점수 비교
|
||||
final currentScore = ItemService.calculateEquipmentScore(currentItem);
|
||||
final newScore = ItemService.calculateEquipmentScore(newItem);
|
||||
|
||||
if (newScore > currentScore) {
|
||||
// 업그레이드: 새 장비 장착
|
||||
return state.copyWith(
|
||||
rng: state.rng,
|
||||
equipment: state.equipment
|
||||
.setItemByIndex(slotIndex, newItem)
|
||||
.copyWith(bestIndex: slotIndex),
|
||||
);
|
||||
} else {
|
||||
// 다운그레이드: 새 장비 판매 (골드로 변환)
|
||||
final shopService = ShopService(rng: state.rng);
|
||||
final sellPrice = shopService.calculateSellPrice(newItem);
|
||||
return state.copyWith(
|
||||
rng: state.rng,
|
||||
inventory: state.inventory.copyWith(
|
||||
gold: state.inventory.gold + sellPrice,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
268
lib/src/core/engine/test_character_service.dart
Normal file
268
lib/src/core/engine/test_character_service.dart
Normal file
@@ -0,0 +1,268 @@
|
||||
import 'package:asciineverdie/src/core/engine/item_service.dart';
|
||||
import 'package:asciineverdie/src/core/model/combat_stats.dart';
|
||||
import 'package:asciineverdie/src/core/model/equipment_item.dart';
|
||||
import 'package:asciineverdie/src/core/model/equipment_slot.dart';
|
||||
import 'package:asciineverdie/src/core/model/game_state.dart';
|
||||
import 'package:asciineverdie/src/core/model/hall_of_fame.dart';
|
||||
import 'package:asciineverdie/src/core/model/item_stats.dart';
|
||||
import 'package:asciineverdie/src/core/model/pq_config.dart';
|
||||
import 'package:asciineverdie/src/core/util/deterministic_random.dart';
|
||||
import 'package:asciineverdie/src/core/util/roman.dart';
|
||||
|
||||
/// 테스트 캐릭터 생성 서비스 (디버그 모드 전용)
|
||||
///
|
||||
/// 현재 캐릭터를 레벨 100, 고급 장비, 다수의 스킬을 가진
|
||||
/// 캐릭터로 변환하여 명예의 전당에 등록할 수 있게 함.
|
||||
class TestCharacterService {
|
||||
TestCharacterService({
|
||||
required this.config,
|
||||
required this.rng,
|
||||
});
|
||||
|
||||
final PqConfig config;
|
||||
final DeterministicRandom rng;
|
||||
|
||||
/// 테스트 캐릭터 데이터 생성
|
||||
///
|
||||
/// 현재 GameState를 기반으로:
|
||||
/// - 레벨 100
|
||||
/// - 장비: Rare+, 최소 Epic 2개, Legendary 1개
|
||||
/// - 스킬: 20~30개 랜덤
|
||||
HallOfFameEntry createTestCharacter(GameState state) {
|
||||
// 1. 장비 생성 (Rare+, Epic 2+, Legendary 1+)
|
||||
final equipment = _generateTestEquipment();
|
||||
|
||||
// 2. 스킬 생성 (20~30개)
|
||||
final skills = _generateTestSkills();
|
||||
|
||||
// 3. 레벨 100 기준 스탯 계산
|
||||
final testStats = _generateTestStats();
|
||||
|
||||
// 4. CombatStats 계산
|
||||
final combatStats = CombatStats.fromStats(
|
||||
stats: testStats,
|
||||
equipment: equipment,
|
||||
level: 100,
|
||||
);
|
||||
|
||||
// 5. HallOfFameEntry 생성
|
||||
return HallOfFameEntry(
|
||||
id: DateTime.now().millisecondsSinceEpoch.toString(),
|
||||
characterName: state.traits.name,
|
||||
race: state.traits.race,
|
||||
raceId: state.traits.raceId,
|
||||
klass: state.traits.klass,
|
||||
level: 100,
|
||||
totalPlayTimeMs: 36000000, // 10시간 (테스트용 기본값)
|
||||
totalDeaths: rng.nextInt(50), // 0~49 랜덤 사망 횟수
|
||||
monstersKilled: 5000 + rng.nextInt(5000), // 5000~10000 처치
|
||||
questsCompleted: 100 + rng.nextInt(50), // 100~150 퀘스트
|
||||
clearedAt: DateTime.now(),
|
||||
finalStats: combatStats,
|
||||
finalEquipment: equipment.items,
|
||||
finalSkills: skills,
|
||||
);
|
||||
}
|
||||
|
||||
/// 테스트용 장비 생성
|
||||
///
|
||||
/// 조건:
|
||||
/// - 모든 장비 Rare 이상
|
||||
/// - 최소 Epic 2개
|
||||
/// - 최소 Legendary 1개
|
||||
Equipment _generateTestEquipment() {
|
||||
final itemService = ItemService(rng: rng);
|
||||
final items = <EquipmentItem>[];
|
||||
|
||||
// 희귀도 분배 결정
|
||||
// 11개 슬롯: 1 Legendary, 2 Epic, 나머지 Rare
|
||||
final rarities = <ItemRarity>[
|
||||
ItemRarity.legendary, // 1개
|
||||
ItemRarity.epic, ItemRarity.epic, // 2개
|
||||
ItemRarity.rare, ItemRarity.rare, ItemRarity.rare, // 3개
|
||||
ItemRarity.rare, ItemRarity.rare, ItemRarity.rare, // 3개
|
||||
ItemRarity.rare, ItemRarity.rare, // 2개
|
||||
];
|
||||
|
||||
// 희귀도 셔플
|
||||
_shuffleList(rarities);
|
||||
|
||||
// 각 슬롯별 장비 생성
|
||||
for (var i = 0; i < Equipment.slotCount; i++) {
|
||||
final slot = EquipmentSlot.values[i];
|
||||
final rarity = rarities[i];
|
||||
final name = _generateEquipmentName(slot, rarity);
|
||||
|
||||
final item = itemService.generateEquipment(
|
||||
name: name,
|
||||
slot: slot,
|
||||
level: 100,
|
||||
rarity: rarity,
|
||||
);
|
||||
items.add(item);
|
||||
}
|
||||
|
||||
// Legendary 슬롯을 bestIndex로 설정
|
||||
final legendaryIndex = rarities.indexOf(ItemRarity.legendary);
|
||||
|
||||
return Equipment(items: items, bestIndex: legendaryIndex);
|
||||
}
|
||||
|
||||
/// 희귀도에 맞는 장비 이름 생성
|
||||
String _generateEquipmentName(EquipmentSlot slot, ItemRarity rarity) {
|
||||
final prefixes = switch (rarity) {
|
||||
ItemRarity.legendary => [
|
||||
'Divine',
|
||||
'Mythical',
|
||||
'Godlike',
|
||||
'Eternal',
|
||||
'Transcendent',
|
||||
],
|
||||
ItemRarity.epic => [
|
||||
'Ancient',
|
||||
'Infernal',
|
||||
'Celestial',
|
||||
'Void',
|
||||
'Corrupted',
|
||||
],
|
||||
ItemRarity.rare => [
|
||||
'Superior',
|
||||
'Enchanted',
|
||||
'Reinforced',
|
||||
'Blessed',
|
||||
'Refined',
|
||||
],
|
||||
_ => ['Standard', 'Basic', 'Common'],
|
||||
};
|
||||
|
||||
final slotNames = switch (slot) {
|
||||
EquipmentSlot.weapon => [
|
||||
'Keyboard of Power',
|
||||
'Binary Blade',
|
||||
'Compiler Crusher',
|
||||
],
|
||||
EquipmentSlot.shield => [
|
||||
'Firewall Shield',
|
||||
'Exception Handler',
|
||||
'Null Guard',
|
||||
],
|
||||
EquipmentSlot.helm => [
|
||||
'Neural Helm',
|
||||
'Thought Processor',
|
||||
'Mind Buffer',
|
||||
],
|
||||
EquipmentSlot.hauberk => [
|
||||
'Matrix Armor',
|
||||
'Byte Mail',
|
||||
'Kernel Plate',
|
||||
],
|
||||
EquipmentSlot.brassairts => [
|
||||
'Bit Guards',
|
||||
'Stream Bracers',
|
||||
'Thread Wraps',
|
||||
],
|
||||
EquipmentSlot.vambraces => [
|
||||
'Code Vambraces',
|
||||
'Debug Cuffs',
|
||||
'Loop Guards',
|
||||
],
|
||||
EquipmentSlot.gauntlets => [
|
||||
'Input Gloves',
|
||||
'Handler Mitts',
|
||||
'Pointer Grips',
|
||||
],
|
||||
EquipmentSlot.gambeson => [
|
||||
'Layer Vest',
|
||||
'Cache Coat',
|
||||
'Buffer Jacket',
|
||||
],
|
||||
EquipmentSlot.cuisses => [
|
||||
'Register Guards',
|
||||
'Stack Protectors',
|
||||
'Heap Leggings',
|
||||
],
|
||||
EquipmentSlot.greaves => [
|
||||
'Runtime Greaves',
|
||||
'Compile Shins',
|
||||
'Execute Boots',
|
||||
],
|
||||
EquipmentSlot.sollerets => [
|
||||
'Boot Loader',
|
||||
'System Treads',
|
||||
'Process Soles',
|
||||
],
|
||||
};
|
||||
|
||||
final prefix = prefixes[rng.nextInt(prefixes.length)];
|
||||
final slotName = slotNames[rng.nextInt(slotNames.length)];
|
||||
|
||||
return '$prefix $slotName';
|
||||
}
|
||||
|
||||
/// 테스트용 스킬 생성 (20~30개)
|
||||
List<Map<String, String>> _generateTestSkills() {
|
||||
final spells = config.spells;
|
||||
final skillCount = 20 + rng.nextInt(11); // 20~30개
|
||||
final selectedSpells = <String>{};
|
||||
final skills = <Map<String, String>>[];
|
||||
|
||||
// 중복 없이 스킬 선택
|
||||
while (selectedSpells.length < skillCount && selectedSpells.length < spells.length) {
|
||||
final index = rng.nextInt(spells.length);
|
||||
final spell = spells[index].split('|')[0];
|
||||
if (selectedSpells.add(spell)) {
|
||||
// 랭크: I~X 랜덤 (레벨 100 기준 높은 랭크 선호)
|
||||
final rank = _randomHighRank();
|
||||
skills.add({'name': spell, 'rank': rank});
|
||||
}
|
||||
}
|
||||
|
||||
return skills;
|
||||
}
|
||||
|
||||
/// 높은 랭크 생성 (V~X 선호)
|
||||
String _randomHighRank() {
|
||||
// 5~10 범위 (V~X)
|
||||
final rankValue = 5 + rng.nextInt(6);
|
||||
return intToRoman(rankValue);
|
||||
}
|
||||
|
||||
/// 테스트용 스탯 생성 (레벨 100 기준)
|
||||
Stats _generateTestStats() {
|
||||
// 레벨 100 기준 높은 스탯
|
||||
// 기본 스탯: 50~80 범위
|
||||
final str = 50 + rng.nextInt(31);
|
||||
final con = 50 + rng.nextInt(31);
|
||||
final dex = 50 + rng.nextInt(31);
|
||||
final int_ = 50 + rng.nextInt(31);
|
||||
final wis = 50 + rng.nextInt(31);
|
||||
final cha = 50 + rng.nextInt(31);
|
||||
|
||||
// HP/MP 계산 (레벨 100 기준)
|
||||
final hpMax = 500 + con * 10;
|
||||
final mpMax = 200 + int_ * 5 + wis * 3;
|
||||
|
||||
return Stats(
|
||||
str: str,
|
||||
con: con,
|
||||
dex: dex,
|
||||
intelligence: int_,
|
||||
wis: wis,
|
||||
cha: cha,
|
||||
hpMax: hpMax,
|
||||
mpMax: mpMax,
|
||||
hpCurrent: hpMax,
|
||||
mpCurrent: mpMax,
|
||||
);
|
||||
}
|
||||
|
||||
/// 리스트 셔플 (Fisher-Yates)
|
||||
void _shuffleList<T>(List<T> list) {
|
||||
for (var i = list.length - 1; i > 0; i--) {
|
||||
final j = rng.nextInt(i + 1);
|
||||
final temp = list[i];
|
||||
list[i] = list[j];
|
||||
list[j] = temp;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -569,6 +569,42 @@ class Equipment {
|
||||
);
|
||||
}
|
||||
|
||||
/// 초보자 장비 세트 (모든 슬롯에 기본 방어구 지급)
|
||||
///
|
||||
/// 원본 PQ에서는 초기 장비가 없지만, 밸런스 개선을 위해
|
||||
/// Act 1 완료 전에도 기본 방어력을 제공.
|
||||
factory Equipment.withStarterGear() {
|
||||
return Equipment(
|
||||
items: [
|
||||
EquipmentItem.defaultWeapon(), // 0: 무기 (Keyboard)
|
||||
_starterArmor('Old Mouse', EquipmentSlot.shield, def: 2),
|
||||
_starterArmor('Cardboard Box', EquipmentSlot.helm, def: 1),
|
||||
_starterArmor('Worn T-Shirt', EquipmentSlot.hauberk, def: 3),
|
||||
_starterArmor('Rubber Band', EquipmentSlot.brassairts, def: 1),
|
||||
_starterArmor('Wristwatch', EquipmentSlot.vambraces, def: 1),
|
||||
_starterArmor('Fingerless Gloves', EquipmentSlot.gauntlets, def: 1),
|
||||
_starterArmor('Hoodie', EquipmentSlot.gambeson, def: 2),
|
||||
_starterArmor('Jeans', EquipmentSlot.cuisses, def: 2),
|
||||
_starterArmor('Knee Pads', EquipmentSlot.greaves, def: 1),
|
||||
_starterArmor('Sneakers', EquipmentSlot.sollerets, def: 1),
|
||||
],
|
||||
bestIndex: 0,
|
||||
);
|
||||
}
|
||||
|
||||
/// 초보자 방어구 생성 헬퍼
|
||||
static EquipmentItem _starterArmor(String name, EquipmentSlot slot,
|
||||
{required int def}) {
|
||||
return EquipmentItem(
|
||||
name: name,
|
||||
slot: slot,
|
||||
level: 1,
|
||||
weight: 1,
|
||||
stats: ItemStats(def: def),
|
||||
rarity: ItemRarity.common,
|
||||
);
|
||||
}
|
||||
|
||||
/// 레거시 문자열 기반 생성자 (세이브 파일 호환용)
|
||||
factory Equipment.fromStrings({
|
||||
required String weapon,
|
||||
|
||||
@@ -150,16 +150,24 @@ class MonsterBaseStats {
|
||||
/// 레벨 기반 기본 스탯 생성
|
||||
///
|
||||
/// HP: 50 + level * 20 + (level^2 / 5)
|
||||
/// ATK: 10 + level * 12 (장비 DEF 스케일링 대응)
|
||||
/// - 장비 DEF ≈ level * 16 (9개 방어구 합산)
|
||||
/// - 데미지 공식: ATK - DEF * 0.5 → 의미있는 피해를 위해 상향
|
||||
/// ATK: 레벨별 차등 적용
|
||||
/// - 레벨 1~5: 2 + level * 3 (초반 난이도 완화)
|
||||
/// - 레벨 6+: 3 + level * 4 (기존 공식)
|
||||
/// - DEF 감산율 0.5와 연동하여 밸런스 조정
|
||||
/// - 플레이어가 5~10회 공격 생존 가능하도록 설계
|
||||
/// DEF: 2 + level * 2
|
||||
/// EXP: 10 + level * 5
|
||||
/// GOLD: 5 + level * 3
|
||||
factory MonsterBaseStats.forLevel(int level) {
|
||||
// 레벨 1~5: 초반 난이도 완화 (2 + level * 3)
|
||||
// 레벨 1=5, 2=8, 3=11, 4=14, 5=17
|
||||
// 레벨 6+: 기존 공식 (3 + level * 4)
|
||||
// 레벨 6=27, 10=43, 20=83, 50=203, 100=403
|
||||
final atk = level <= 5 ? 2 + level * 3 : 3 + level * 4;
|
||||
|
||||
return MonsterBaseStats(
|
||||
hp: 50 + level * 20 + (level * level ~/ 5),
|
||||
atk: 10 + level * 12,
|
||||
atk: atk,
|
||||
def: 2 + level * 2,
|
||||
exp: 10 + level * 5,
|
||||
gold: 5 + level * 3,
|
||||
@@ -447,14 +455,14 @@ class ActMonsterLevel {
|
||||
class PlayerScaling {
|
||||
PlayerScaling._();
|
||||
|
||||
/// 레벨당 HP 증가량 (10 → 12 상향)
|
||||
static const int hpPerLevel = 12;
|
||||
/// 레벨당 HP 증가량 (12 → 18 상향, 생존율 개선)
|
||||
static const int hpPerLevel = 18;
|
||||
|
||||
/// 레벨당 MP 증가량 (5 → 6 상향)
|
||||
static const int mpPerLevel = 6;
|
||||
|
||||
/// CON당 HP 보너스 (5 → 6 상향)
|
||||
static const int hpPerCon = 6;
|
||||
/// CON당 HP 보너스 (6 → 10 상향, 체력 투자 효율 개선)
|
||||
static const int hpPerCon = 10;
|
||||
|
||||
/// INT당 MP 보너스 (3 → 4 상향)
|
||||
static const int mpPerInt = 4;
|
||||
|
||||
@@ -798,12 +798,15 @@ ActResult completeAct(int existingActCount) {
|
||||
: 3600;
|
||||
|
||||
final rewards = <RewardKind>[];
|
||||
// 프롤로그 완료 시(existingActCount=1)부터 장비 보상 지급
|
||||
// 원본: existingActCount > 2 (Act II 이후)
|
||||
// 수정: existingActCount >= 1 (프롤로그 완료 후)
|
||||
if (existingActCount >= 1) {
|
||||
rewards.add(RewardKind.equip);
|
||||
}
|
||||
if (existingActCount > 1) {
|
||||
rewards.add(RewardKind.item);
|
||||
}
|
||||
if (existingActCount > 2) {
|
||||
rewards.add(RewardKind.equip);
|
||||
}
|
||||
|
||||
return ActResult(
|
||||
actTitle: title,
|
||||
|
||||
@@ -13,58 +13,41 @@ const _romanMap = <String, int>{
|
||||
};
|
||||
|
||||
String intToRoman(int n) {
|
||||
if (n <= 0) return '';
|
||||
|
||||
final buffer = StringBuffer();
|
||||
void emit(int value, String numeral) {
|
||||
|
||||
// 로마 숫자 변환 테이블 (내림차순)
|
||||
const values = [
|
||||
(10000, 'T'),
|
||||
(9000, 'MT'),
|
||||
(5000, 'A'),
|
||||
(4000, 'MA'),
|
||||
(1000, 'M'),
|
||||
(900, 'CM'),
|
||||
(500, 'D'),
|
||||
(400, 'CD'),
|
||||
(100, 'C'),
|
||||
(90, 'XC'),
|
||||
(50, 'L'),
|
||||
(40, 'XL'),
|
||||
(10, 'X'),
|
||||
(9, 'IX'),
|
||||
(5, 'V'),
|
||||
(4, 'IV'),
|
||||
(1, 'I'),
|
||||
];
|
||||
|
||||
for (final (value, numeral) in values) {
|
||||
while (n >= value) {
|
||||
buffer.write(numeral);
|
||||
n -= value;
|
||||
}
|
||||
}
|
||||
|
||||
emit(10000, 'T');
|
||||
if (n >= 9000) {
|
||||
buffer.write('MT');
|
||||
n -= 9000;
|
||||
}
|
||||
if (n >= 5000) {
|
||||
buffer.write('A');
|
||||
n -= 5000;
|
||||
}
|
||||
if (n >= 4000) {
|
||||
buffer.write('MA');
|
||||
n -= 4000;
|
||||
}
|
||||
|
||||
emit(1000, 'M');
|
||||
_subtract(ref: n, target: 900, numeral: 'CM', buffer: buffer);
|
||||
_subtract(ref: n, target: 500, numeral: 'D', buffer: buffer);
|
||||
_subtract(ref: n, target: 400, numeral: 'CD', buffer: buffer);
|
||||
|
||||
emit(100, 'C');
|
||||
_subtract(ref: n, target: 90, numeral: 'XC', buffer: buffer);
|
||||
_subtract(ref: n, target: 50, numeral: 'L', buffer: buffer);
|
||||
_subtract(ref: n, target: 40, numeral: 'XL', buffer: buffer);
|
||||
|
||||
emit(10, 'X');
|
||||
_subtract(ref: n, target: 9, numeral: 'IX', buffer: buffer);
|
||||
_subtract(ref: n, target: 5, numeral: 'V', buffer: buffer);
|
||||
_subtract(ref: n, target: 4, numeral: 'IV', buffer: buffer);
|
||||
emit(1, 'I');
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
void _subtract({
|
||||
required int ref,
|
||||
required int target,
|
||||
required String numeral,
|
||||
required StringBuffer buffer,
|
||||
}) {
|
||||
if (ref >= target) {
|
||||
buffer.write(numeral);
|
||||
ref -= target;
|
||||
}
|
||||
}
|
||||
|
||||
int romanToInt(String n) {
|
||||
var result = 0;
|
||||
var i = 0;
|
||||
|
||||
@@ -102,6 +102,9 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
||||
|
||||
// 사망/엔딩 상태 추적 (BGM 전환용)
|
||||
bool _wasDead = false;
|
||||
|
||||
// 사운드 디바운스 추적 (배속 시 사운드 누락 방지)
|
||||
final Map<String, int> _lastSfxPlayTime = {};
|
||||
bool _wasComplete = false;
|
||||
|
||||
// 사운드 볼륨 상태 (모바일 설정 UI용)
|
||||
@@ -344,47 +347,53 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
||||
_wasInBattleTask = isInBattleTask;
|
||||
}
|
||||
|
||||
/// 전투 이벤트별 SFX 재생 (채널 분리)
|
||||
/// 전투 이벤트별 SFX 재생 (채널 분리 + 디바운스)
|
||||
///
|
||||
/// 플레이어 이펙트와 몬스터 이펙트를 별도 채널에서 재생하여
|
||||
/// 사운드 충돌을 방지하고 완료를 보장합니다.
|
||||
/// 배속 모드에서는 디바운스를 적용하여 사운드 누락을 방지합니다.
|
||||
void _playCombatEventSfx(CombatEvent event) {
|
||||
final audio = widget.audioService;
|
||||
if (audio == null) return;
|
||||
|
||||
switch (event.type) {
|
||||
// 플레이어 채널: 플레이어가 발생시키는 이펙트
|
||||
case CombatEventType.playerAttack:
|
||||
audio.playPlayerSfx('attack');
|
||||
case CombatEventType.playerSkill:
|
||||
audio.playPlayerSfx('skill');
|
||||
case CombatEventType.playerHeal:
|
||||
case CombatEventType.playerPotion:
|
||||
case CombatEventType.potionDrop:
|
||||
audio.playPlayerSfx('item');
|
||||
case CombatEventType.playerBuff:
|
||||
case CombatEventType.playerDebuff:
|
||||
audio.playPlayerSfx('skill');
|
||||
// 사운드 이름 결정
|
||||
final sfxName = switch (event.type) {
|
||||
CombatEventType.playerAttack => 'attack',
|
||||
CombatEventType.playerSkill => 'skill',
|
||||
CombatEventType.playerHeal => 'item',
|
||||
CombatEventType.playerPotion => 'item',
|
||||
CombatEventType.potionDrop => 'item',
|
||||
CombatEventType.playerBuff => 'skill',
|
||||
CombatEventType.playerDebuff => 'skill',
|
||||
CombatEventType.monsterAttack => 'hit',
|
||||
CombatEventType.playerEvade => 'evade',
|
||||
CombatEventType.monsterEvade => 'evade',
|
||||
CombatEventType.playerBlock => 'block',
|
||||
CombatEventType.playerParry => 'parry',
|
||||
CombatEventType.dotTick => null, // DOT 틱은 SFX 없음
|
||||
};
|
||||
|
||||
// 몬스터 채널: 몬스터가 발생시키는 이펙트 (플레이어 피격)
|
||||
case CombatEventType.monsterAttack:
|
||||
audio.playMonsterSfx('hit');
|
||||
if (sfxName == null) return;
|
||||
|
||||
// 회피/방어 SFX (Phase 11)
|
||||
case CombatEventType.playerEvade:
|
||||
audio.playPlayerSfx('evade');
|
||||
case CombatEventType.monsterEvade:
|
||||
// 몬스터 회피 = 플레이어 공격 빗나감 (evade SFX)
|
||||
audio.playPlayerSfx('evade');
|
||||
case CombatEventType.playerBlock:
|
||||
audio.playPlayerSfx('block');
|
||||
case CombatEventType.playerParry:
|
||||
audio.playPlayerSfx('parry');
|
||||
// 디바운스 체크 (배속 시 같은 사운드 100ms 내 중복 재생 방지)
|
||||
final now = DateTime.now().millisecondsSinceEpoch;
|
||||
final lastTime = _lastSfxPlayTime[sfxName] ?? 0;
|
||||
final speedMultiplier = widget.controller.loop?.speedMultiplier ?? 1;
|
||||
|
||||
// SFX 없음
|
||||
case CombatEventType.dotTick:
|
||||
// DOT 틱은 SFX 없음 (너무 자주 발생)
|
||||
break;
|
||||
// 배속이 높을수록 디바운스 간격 증가 (1x=50ms, 8x=150ms)
|
||||
final debounceMs = 50 + (speedMultiplier - 1) * 15;
|
||||
|
||||
if (now - lastTime < debounceMs) {
|
||||
return; // 디바운스 기간 내 → 스킵
|
||||
}
|
||||
_lastSfxPlayTime[sfxName] = now;
|
||||
|
||||
// 채널별 재생
|
||||
final isMonsterSfx = event.type == CombatEventType.monsterAttack;
|
||||
if (isMonsterSfx) {
|
||||
audio.playMonsterSfx(sfxName);
|
||||
} else {
|
||||
audio.playPlayerSfx(sfxName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -743,6 +752,14 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
||||
setState(() => _sfxVolume = volume);
|
||||
widget.audioService?.setSfxVolume(volume);
|
||||
},
|
||||
onCreateTestCharacter: () async {
|
||||
final navigator = Navigator.of(context);
|
||||
final success = await widget.controller.createTestCharacter();
|
||||
if (success && mounted) {
|
||||
// 프론트 화면으로 이동
|
||||
navigator.popUntil((route) => route.isFirst);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -886,6 +903,13 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
||||
onCheatQuest: () =>
|
||||
widget.controller.loop?.cheatCompleteQuest(),
|
||||
onCheatPlot: () => widget.controller.loop?.cheatCompletePlot(),
|
||||
onCreateTestCharacter: () async {
|
||||
final navigator = Navigator.of(context);
|
||||
final success = await widget.controller.createTestCharacter();
|
||||
if (success && mounted) {
|
||||
navigator.popUntil((route) => route.isFirst);
|
||||
}
|
||||
},
|
||||
),
|
||||
// 사망 오버레이
|
||||
if (state.isDead && state.deathInfo != null)
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:asciineverdie/src/core/engine/progress_loop.dart';
|
||||
import 'package:asciineverdie/src/core/engine/progress_service.dart';
|
||||
import 'package:asciineverdie/src/core/engine/resurrection_service.dart';
|
||||
import 'package:asciineverdie/src/core/engine/shop_service.dart';
|
||||
import 'package:asciineverdie/src/core/engine/test_character_service.dart';
|
||||
import 'package:asciineverdie/src/core/model/combat_stats.dart';
|
||||
import 'package:asciineverdie/src/core/model/game_state.dart';
|
||||
import 'package:asciineverdie/src/core/model/game_statistics.dart';
|
||||
@@ -367,6 +368,58 @@ class GameSessionController extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// 테스트 캐릭터 생성 (디버그 모드 전용)
|
||||
///
|
||||
/// 현재 캐릭터를 레벨 100, 고급 장비, 다수의 스킬을 가진
|
||||
/// 캐릭터로 변환하여 명예의 전당에 등록하고 세이브를 삭제함.
|
||||
Future<bool> createTestCharacter() async {
|
||||
if (_state == null) {
|
||||
debugPrint('[TestCharacter] _state is null');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
debugPrint('[TestCharacter] Creating test character...');
|
||||
|
||||
// 게임 일시정지
|
||||
await _stopLoop(saveOnStop: false);
|
||||
|
||||
// TestCharacterService로 테스트 캐릭터 생성
|
||||
final testService = TestCharacterService(
|
||||
config: progressService.config,
|
||||
rng: _state!.rng,
|
||||
);
|
||||
|
||||
final entry = testService.createTestCharacter(_state!);
|
||||
|
||||
debugPrint(
|
||||
'[TestCharacter] Entry created: ${entry.characterName} Lv.${entry.level}',
|
||||
);
|
||||
|
||||
// 명예의 전당에 등록
|
||||
final success = await _hallOfFameStorage.addEntry(entry);
|
||||
debugPrint('[TestCharacter] HallOfFame save result: $success');
|
||||
|
||||
if (success) {
|
||||
// 세이브 파일 삭제
|
||||
final deleteResult = await saveManager.deleteSave();
|
||||
debugPrint('[TestCharacter] Save deleted: ${deleteResult.success}');
|
||||
}
|
||||
|
||||
// 상태 초기화
|
||||
_state = null;
|
||||
_status = GameSessionStatus.idle;
|
||||
notifyListeners();
|
||||
|
||||
debugPrint('[TestCharacter] Complete');
|
||||
return success;
|
||||
} catch (e, st) {
|
||||
debugPrint('[TestCharacter] ERROR: $e');
|
||||
debugPrint('[TestCharacter] StackTrace: $st');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 플레이어 부활 처리 (상태만 업데이트, 게임 재개는 별도로)
|
||||
///
|
||||
/// HP/MP 회복, 빈 슬롯에 장비 자동 구매
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:flutter/foundation.dart' show kDebugMode;
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:asciineverdie/src/core/notification/notification_service.dart';
|
||||
@@ -50,6 +51,7 @@ class MobileCarouselLayout extends StatefulWidget {
|
||||
this.onCheatTask,
|
||||
this.onCheatQuest,
|
||||
this.onCheatPlot,
|
||||
this.onCreateTestCharacter,
|
||||
});
|
||||
|
||||
final GameState state;
|
||||
@@ -97,6 +99,9 @@ class MobileCarouselLayout extends StatefulWidget {
|
||||
/// 치트: 액트(플롯) 완료
|
||||
final VoidCallback? onCheatPlot;
|
||||
|
||||
/// 테스트 캐릭터 생성 콜백 (디버그 모드 전용)
|
||||
final Future<void> Function()? onCreateTestCharacter;
|
||||
|
||||
@override
|
||||
State<MobileCarouselLayout> createState() => _MobileCarouselLayoutState();
|
||||
}
|
||||
@@ -364,6 +369,39 @@ class _MobileCarouselLayoutState extends State<MobileCarouselLayout> {
|
||||
);
|
||||
}
|
||||
|
||||
/// 테스트 캐릭터 생성 확인 다이얼로그
|
||||
Future<void> _showTestCharacterDialog(BuildContext context) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Create Test Character?'),
|
||||
content: const Text(
|
||||
'현재 캐릭터가 레벨 100으로 변환되어 명예의 전당에 등록됩니다.\n\n'
|
||||
'⚠️ 현재 세이브 파일이 삭제됩니다.\n'
|
||||
'이 작업은 되돌릴 수 없습니다.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
foregroundColor: Theme.of(context).colorScheme.onError,
|
||||
),
|
||||
child: const Text('Create'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true && mounted) {
|
||||
await widget.onCreateTestCharacter?.call();
|
||||
}
|
||||
}
|
||||
|
||||
/// 옵션 메뉴 표시
|
||||
void _showOptionsMenu(BuildContext context) {
|
||||
final localizations = L10n.of(context);
|
||||
@@ -609,6 +647,34 @@ class _MobileCarouselLayoutState extends State<MobileCarouselLayout> {
|
||||
),
|
||||
],
|
||||
|
||||
// 디버그 도구 섹션 (kDebugMode에서만 표시)
|
||||
if (kDebugMode && widget.onCreateTestCharacter != null) ...[
|
||||
const Divider(),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 8,
|
||||
),
|
||||
child: Text(
|
||||
'DEBUG TOOLS',
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 8,
|
||||
color: Colors.orange.shade300,
|
||||
),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.science, color: Colors.orange),
|
||||
title: const Text('Create Test Character'),
|
||||
subtitle: const Text('레벨 100 캐릭터를 명예의 전당에 등록'),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
_showTestCharacterDialog(context);
|
||||
},
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:asciineverdie/data/game_text_l10n.dart' as game_l10n;
|
||||
@@ -15,6 +16,7 @@ class SettingsScreen extends StatefulWidget {
|
||||
this.onLocaleChange,
|
||||
this.onBgmVolumeChange,
|
||||
this.onSfxVolumeChange,
|
||||
this.onCreateTestCharacter,
|
||||
});
|
||||
|
||||
final SettingsRepository settingsRepository;
|
||||
@@ -28,6 +30,11 @@ class SettingsScreen extends StatefulWidget {
|
||||
/// SFX 볼륨 변경 콜백 (AudioService 연동용)
|
||||
final void Function(double volume)? onSfxVolumeChange;
|
||||
|
||||
/// 테스트 캐릭터 생성 콜백 (디버그 모드 전용)
|
||||
///
|
||||
/// 현재 캐릭터를 레벨 100으로 만들어 명예의 전당에 등록하고 세이브 삭제
|
||||
final Future<void> Function()? onCreateTestCharacter;
|
||||
|
||||
@override
|
||||
State<SettingsScreen> createState() => _SettingsScreenState();
|
||||
|
||||
@@ -40,6 +47,7 @@ class SettingsScreen extends StatefulWidget {
|
||||
void Function(String locale)? onLocaleChange,
|
||||
void Function(double volume)? onBgmVolumeChange,
|
||||
void Function(double volume)? onSfxVolumeChange,
|
||||
Future<void> Function()? onCreateTestCharacter,
|
||||
}) {
|
||||
return showModalBottomSheet<void>(
|
||||
context: context,
|
||||
@@ -57,6 +65,7 @@ class SettingsScreen extends StatefulWidget {
|
||||
onLocaleChange: onLocaleChange,
|
||||
onBgmVolumeChange: onBgmVolumeChange,
|
||||
onSfxVolumeChange: onSfxVolumeChange,
|
||||
onCreateTestCharacter: onCreateTestCharacter,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -180,6 +189,13 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
// 정보
|
||||
_buildSectionTitle(game_l10n.uiAbout),
|
||||
_buildAboutCard(),
|
||||
|
||||
// 디버그 섹션 (디버그 모드에서만 표시)
|
||||
if (kDebugMode && widget.onCreateTestCharacter != null) ...[
|
||||
const SizedBox(height: 24),
|
||||
_buildSectionTitle('Debug'),
|
||||
_buildDebugSection(),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -188,6 +204,90 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDebugSection() {
|
||||
return Card(
|
||||
color: Theme.of(context).colorScheme.errorContainer.withValues(alpha: 0.3),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.bug_report,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Developer Tools',
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'현재 캐릭터를 레벨 100으로 수정하여 명예의 전당에 등록합니다. '
|
||||
'등록 후 현재 세이브 파일이 삭제됩니다.',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: _handleCreateTestCharacter,
|
||||
icon: const Icon(Icons.science),
|
||||
label: const Text('Create Test Character'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
foregroundColor: Theme.of(context).colorScheme.onError,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handleCreateTestCharacter() async {
|
||||
// 확인 다이얼로그 표시
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Create Test Character?'),
|
||||
content: const Text(
|
||||
'현재 캐릭터가 레벨 100으로 변환되어 명예의 전당에 등록됩니다.\n\n'
|
||||
'⚠️ 현재 세이브 파일이 삭제됩니다.\n'
|
||||
'이 작업은 되돌릴 수 없습니다.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
foregroundColor: Theme.of(context).colorScheme.onError,
|
||||
),
|
||||
child: const Text('Create'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true && mounted) {
|
||||
await widget.onCreateTestCharacter?.call();
|
||||
if (mounted) {
|
||||
Navigator.of(context).pop(); // 설정 화면 닫기
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildSectionTitle(String title) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
|
||||
@@ -52,7 +52,7 @@ void main() {
|
||||
service = ProgressService(
|
||||
config: config,
|
||||
mutations: mutations,
|
||||
rewards: RewardService(mutations),
|
||||
rewards: RewardService(mutations, config),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ void main() {
|
||||
service = ProgressService(
|
||||
config: config,
|
||||
mutations: mutations,
|
||||
rewards: RewardService(mutations),
|
||||
rewards: RewardService(mutations, config),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
207
test/core/util/balance_analysis_test.dart
Normal file
207
test/core/util/balance_analysis_test.dart
Normal file
@@ -0,0 +1,207 @@
|
||||
// ignore_for_file: avoid_print
|
||||
import 'package:asciineverdie/src/core/model/item_stats.dart';
|
||||
import 'package:asciineverdie/src/core/util/balance_constants.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
/// 전체 레벨 밸런스 분석 테스트
|
||||
///
|
||||
/// 이 테스트는 플레이어와 몬스터의 스탯 스케일링을 분석하고
|
||||
/// 예상 생존율을 계산합니다.
|
||||
void main() {
|
||||
group('전체 레벨 밸런스 분석', () {
|
||||
test('레벨별 몬스터 vs 플레이어 스탯 비교', () {
|
||||
print('\n${'=' * 80}');
|
||||
print('레벨별 밸런스 분석 리포트');
|
||||
print('${'=' * 80}\n');
|
||||
|
||||
// 분석할 레벨
|
||||
final levels = [1, 5, 8, 10, 20, 30, 50, 80, 100];
|
||||
|
||||
print('### 1. 몬스터 스탯 (현재 공식)');
|
||||
print('| 레벨 | HP | ATK | DEF | EXP | 타입 |');
|
||||
print('|------|-----|-----|-----|-----|------|');
|
||||
for (final level in levels) {
|
||||
final m = MonsterBaseStats.forLevel(level);
|
||||
print(
|
||||
'| ${level.toString().padLeft(4)} | ${m.hp.toString().padLeft(4)} '
|
||||
'| ${m.atk.toString().padLeft(3)} | ${m.def.toString().padLeft(3)} '
|
||||
'| ${m.exp.toString().padLeft(3)} | Normal |',
|
||||
);
|
||||
}
|
||||
|
||||
print('\n### 2. 플레이어 예상 스탯 (장비 포함)');
|
||||
print('| 레벨 | HP | ATK | DEF | 장비DEF |');
|
||||
print('|------|-----|-----|-----|---------|');
|
||||
for (final level in levels) {
|
||||
final player = _estimatePlayerStats(level);
|
||||
print(
|
||||
'| ${level.toString().padLeft(4)} | ${player.hp.toString().padLeft(4)} '
|
||||
'| ${player.atk.toString().padLeft(3)} | ${player.def.toString().padLeft(3)} '
|
||||
'| ${player.equipDef.toString().padLeft(7)} |',
|
||||
);
|
||||
}
|
||||
|
||||
print('\n### 3. 전투 시뮬레이션 (평균 데미지)');
|
||||
print('| 레벨 | 몬→플 | 플→몬 | 플생존 | 몬생존 | 승률예상 |');
|
||||
print('|------|-------|-------|--------|--------|----------|');
|
||||
for (final level in levels) {
|
||||
final sim = _simulateCombat(level);
|
||||
print(
|
||||
'| ${level.toString().padLeft(4)} '
|
||||
'| ${sim.monsterDamage.toString().padLeft(5)} '
|
||||
'| ${sim.playerDamage.toString().padLeft(5)} '
|
||||
'| ${sim.playerHits.toString().padLeft(6)} '
|
||||
'| ${sim.monsterHits.toString().padLeft(6)} '
|
||||
'| ${(sim.winRate * 100).toStringAsFixed(0).padLeft(8)}% |',
|
||||
);
|
||||
}
|
||||
|
||||
print('\n### 4. 밸런스 진단');
|
||||
for (final level in levels) {
|
||||
final sim = _simulateCombat(level);
|
||||
final tier = LevelTierSettings.forLevel(level);
|
||||
final targetWinRate = 1.0 - tier.targetDeathRate;
|
||||
final diagnosis = sim.winRate >= targetWinRate
|
||||
? '✓ 적정'
|
||||
: '✗ 조정필요 (목표: ${(targetWinRate * 100).toStringAsFixed(0)}%)';
|
||||
print('레벨 $level (${tier.name}): 승률 ${(sim.winRate * 100).toStringAsFixed(0)}% $diagnosis');
|
||||
}
|
||||
|
||||
print('\n${'=' * 80}');
|
||||
});
|
||||
|
||||
test('문제점 및 개선 제안', () {
|
||||
print('\n### 현재 밸런스 문제점\n');
|
||||
|
||||
// 레벨 8 상세 분석 (사용자 보고 케이스)
|
||||
final level = 8;
|
||||
final monster = MonsterBaseStats.forLevel(level);
|
||||
final player = _estimatePlayerStats(level);
|
||||
|
||||
print('#### 레벨 8 상세 분석 (사용자 보고 케이스)');
|
||||
print('- 몬스터 ATK: ${monster.atk}');
|
||||
print('- 플레이어 HP: ${player.hp}');
|
||||
print('- 플레이어 DEF: ${player.def}');
|
||||
|
||||
// 데미지 계산 (combat_calculator 공식)
|
||||
// damage = ATK * 0.8~1.2 - DEF * 0.3
|
||||
final minDamage = (monster.atk * 0.8 - player.def * 0.3).round();
|
||||
final maxDamage = (monster.atk * 1.2 - player.def * 0.3).round();
|
||||
print('- 몬스터 데미지: $minDamage ~ $maxDamage');
|
||||
print('- 플레이어 생존 히트: ${(player.hp / maxDamage).floor()} ~ ${(player.hp / minDamage).ceil()}');
|
||||
|
||||
print('\n#### 적용된 밸런스 수정');
|
||||
print('1. 플레이어 HP 스케일링 상향:');
|
||||
print(' - hpPerLevel: 12 → 18 (50% 증가)');
|
||||
print(' - hpPerCon: 6 → 10 (67% 증가)');
|
||||
print('2. 데미지 공식 DEF 감산율 상향:');
|
||||
print(' - defenderDef * 0.3 → defenderDef * 0.5 (방어력 효과 67% 증가)');
|
||||
print('3. 몬스터 ATK 대폭 하향:');
|
||||
print(' - 5 + level * 6 → 3 + level * 4 (33% 감소)');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// 플레이어 예상 스탯
|
||||
class _PlayerEstimate {
|
||||
final int hp;
|
||||
final int atk;
|
||||
final int def;
|
||||
final int equipDef;
|
||||
|
||||
_PlayerEstimate({
|
||||
required this.hp,
|
||||
required this.atk,
|
||||
required this.def,
|
||||
required this.equipDef,
|
||||
});
|
||||
}
|
||||
|
||||
/// 레벨별 플레이어 스탯 추정
|
||||
///
|
||||
/// 가정:
|
||||
/// - 평균 스탯: STR/CON/DEX = 15 (3d6 평균 10.5 + 종족/클래스 보정)
|
||||
/// - 장비: 레벨 * 0.8 수준의 평균 장비
|
||||
_PlayerEstimate _estimatePlayerStats(int level) {
|
||||
// 기본 스탯 (평균)
|
||||
const str = 15;
|
||||
const con = 15;
|
||||
// dex는 회피/크리티컬에 영향, 여기서는 HP/ATK/DEF만 분석
|
||||
|
||||
// 기본 HP (종족별 다르지만 평균 ~10 가정)
|
||||
const baseHp = 10;
|
||||
// PlayerScaling 공식: hpMax = baseHp + (level - 1) * hpPerLevel + conBonus * hpPerCon
|
||||
// 수정: hpPerLevel 12→18, hpPerCon 6→10
|
||||
final hpMax = baseHp + (level - 1) * 18 + (con - 10) * 10;
|
||||
|
||||
// 장비 스탯 추정 (레벨 * 0.8 수준의 common 장비 10개)
|
||||
final equipLevel = (level * 0.8).round().clamp(1, level);
|
||||
final equipBaseValue = (equipLevel * 1.2 * ItemRarity.common.multiplier).round();
|
||||
|
||||
// 무기 ATK (speedMultiplier 1.0 가정)
|
||||
final weaponAtk = equipBaseValue;
|
||||
|
||||
// 방어구 DEF (10개 슬롯, 평균 multiplier 0.85)
|
||||
final armorDef = (equipBaseValue * 0.85 * 10).round();
|
||||
|
||||
// CombatStats.fromStats 공식
|
||||
// baseAtk = effectiveStr * 2 + level + equipStats.atk
|
||||
final baseAtk = str * 2 + level + weaponAtk;
|
||||
// baseDef = effectiveCon + (level ~/ 2) + equipStats.def
|
||||
final baseDef = con + (level ~/ 2) + armorDef;
|
||||
|
||||
return _PlayerEstimate(
|
||||
hp: hpMax,
|
||||
atk: baseAtk,
|
||||
def: baseDef,
|
||||
equipDef: armorDef,
|
||||
);
|
||||
}
|
||||
|
||||
/// 전투 시뮬레이션 결과
|
||||
class _CombatSimulation {
|
||||
final int monsterDamage;
|
||||
final int playerDamage;
|
||||
final int playerHits; // 플레이어가 버틸 수 있는 히트 수
|
||||
final int monsterHits; // 몬스터를 죽이는데 필요한 히트 수
|
||||
final double winRate;
|
||||
|
||||
_CombatSimulation({
|
||||
required this.monsterDamage,
|
||||
required this.playerDamage,
|
||||
required this.playerHits,
|
||||
required this.monsterHits,
|
||||
required this.winRate,
|
||||
});
|
||||
}
|
||||
|
||||
/// 전투 시뮬레이션
|
||||
_CombatSimulation _simulateCombat(int level) {
|
||||
final monster = MonsterBaseStats.forLevel(level);
|
||||
final player = _estimatePlayerStats(level);
|
||||
|
||||
// 데미지 계산 (combat_calculator 평균)
|
||||
// damage = ATK * 1.0 - DEF * 0.5 (DEF 감산율 상향)
|
||||
final monsterDamage = (monster.atk * 1.0 - player.def * 0.5).round().clamp(1, 9999);
|
||||
final playerDamage = (player.atk * 1.0 - monster.def * 0.5).round().clamp(1, 9999);
|
||||
|
||||
// 생존 히트 수
|
||||
final playerHits = (player.hp / monsterDamage).ceil();
|
||||
final monsterHits = (monster.hp / playerDamage).ceil();
|
||||
|
||||
// 승률 추정 (히트 비율 기반)
|
||||
// 플레이어가 먼저 공격한다고 가정하면, playerHits > monsterHits면 승리
|
||||
final winRate = playerHits > monsterHits
|
||||
? 0.95 // 압도적 유리
|
||||
: playerHits == monsterHits
|
||||
? 0.65 // 동등 (선공 이점)
|
||||
: (playerHits / monsterHits).clamp(0.2, 0.9);
|
||||
|
||||
return _CombatSimulation(
|
||||
monsterDamage: monsterDamage,
|
||||
playerDamage: playerDamage,
|
||||
playerHits: playerHits,
|
||||
monsterHits: monsterHits,
|
||||
winRate: winRate,
|
||||
);
|
||||
}
|
||||
92
test/core/util/balance_constants_test.dart
Normal file
92
test/core/util/balance_constants_test.dart
Normal file
@@ -0,0 +1,92 @@
|
||||
import 'package:asciineverdie/src/core/util/balance_constants.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
group('MonsterBaseStats ATK 완화 공식', () {
|
||||
test('ATK 레벨별 차등 공식 검증', () {
|
||||
// 레벨 1~5: 2 + level * 3 (초반 난이도 완화)
|
||||
expect(MonsterBaseStats.forLevel(1).atk, 5); // 2 + 3
|
||||
expect(MonsterBaseStats.forLevel(5).atk, 17); // 2 + 15
|
||||
// 레벨 6+: 3 + level * 4
|
||||
expect(MonsterBaseStats.forLevel(8).atk, 35); // 3 + 32
|
||||
expect(MonsterBaseStats.forLevel(10).atk, 43); // 3 + 40
|
||||
expect(MonsterBaseStats.forLevel(20).atk, 83); // 3 + 80
|
||||
expect(MonsterBaseStats.forLevel(100).atk, 403); // 3 + 400
|
||||
});
|
||||
|
||||
test('레벨 8 몬스터가 플레이어에게 적절한 데미지', () {
|
||||
// 레벨 8 몬스터: ATK 35
|
||||
// 플레이어 DEF ~40 가정 (중간 수준 장비)
|
||||
// 최대 데미지: 35 * 1.2 - 40 * 0.5 = 42 - 20 = 22
|
||||
final level8Atk = MonsterBaseStats.forLevel(8).atk;
|
||||
final maxDamage = (level8Atk * 1.2 - 40 * 0.5).round();
|
||||
// 플레이어 HP ~150 기준 6~7회 생존
|
||||
expect(maxDamage, lessThan(50));
|
||||
expect(maxDamage, greaterThan(10));
|
||||
});
|
||||
|
||||
test('레벨 1 몬스터 데미지가 플레이어 1히트킬 방지', () {
|
||||
final level1Atk = MonsterBaseStats.forLevel(1).atk;
|
||||
// ATK 5에서 DEF 10 기준 최대 데미지 = 5 * 1.2 - 10 * 0.5 = 1
|
||||
final maxDamage = (level1Atk * 1.2 - 10 * 0.5).round();
|
||||
expect(maxDamage, lessThanOrEqualTo(10));
|
||||
});
|
||||
|
||||
test('ATK 곡선이 단조 증가', () {
|
||||
int prevAtk = 0;
|
||||
for (var level = 1; level <= 50; level++) {
|
||||
final atk = MonsterBaseStats.forLevel(level).atk;
|
||||
expect(
|
||||
atk,
|
||||
greaterThan(prevAtk),
|
||||
reason: 'Level $level ATK should be > level ${level - 1}',
|
||||
);
|
||||
prevAtk = atk;
|
||||
}
|
||||
});
|
||||
|
||||
test('기존 공식 대비 ATK 감소 확인', () {
|
||||
// 기존: 10 + level * 12
|
||||
// 신규 (레벨 1~5): 2 + level * 3 (초반 난이도 완화)
|
||||
// 신규 (레벨 6+): 3 + level * 4
|
||||
|
||||
// 레벨 6 이상은 약 33% 수준 (기존 대비 67% 감소)
|
||||
for (var level in [10, 20]) {
|
||||
final oldAtk = 10 + level * 12;
|
||||
final newAtk = MonsterBaseStats.forLevel(level).atk;
|
||||
final ratio = newAtk / oldAtk;
|
||||
expect(ratio, lessThan(0.45), reason: 'Level $level should be < 45% of old');
|
||||
expect(ratio, greaterThan(0.25), reason: 'Level $level should be > 25% of old');
|
||||
}
|
||||
|
||||
// 레벨 1~5는 추가 완화 (기존 대비 더 낮음)
|
||||
for (var level in [1, 5]) {
|
||||
final oldAtk = 10 + level * 12;
|
||||
final newAtk = MonsterBaseStats.forLevel(level).atk;
|
||||
final ratio = newAtk / oldAtk;
|
||||
expect(ratio, lessThan(0.35), reason: 'Early level $level should be < 35% of old');
|
||||
expect(ratio, greaterThan(0.15), reason: 'Early level $level should be > 15% of old');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
group('PlayerScaling HP 스케일링', () {
|
||||
test('hpPerLevel = 18, hpPerCon = 10 검증', () {
|
||||
expect(PlayerScaling.hpPerLevel, 18);
|
||||
expect(PlayerScaling.hpPerCon, 10);
|
||||
});
|
||||
|
||||
test('레벨업당 HP 증가 검증', () {
|
||||
// baseHp=10, level=10, conBonus=5
|
||||
final result = PlayerScaling.calculateResources(
|
||||
level: 10,
|
||||
baseHp: 10,
|
||||
baseMp: 20,
|
||||
conBonus: 5,
|
||||
intBonus: 5,
|
||||
);
|
||||
// HP = 10 + (10-1)*18 + 5*10 = 10 + 162 + 50 = 222
|
||||
expect(result.hpMax, 222);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -130,7 +130,8 @@ void main() {
|
||||
// 새 배열 기반: Act II = 10800초 (3시간) - 10시간 완주 목표
|
||||
expect(act2.plotBarMaxSeconds, 10800);
|
||||
expect(act2.rewards, contains(pq_logic.RewardKind.item));
|
||||
expect(act2.rewards, isNot(contains(pq_logic.RewardKind.equip)));
|
||||
// 장비 보상: existingActCount >= 1 부터 지급 (프롤로그 완료 시점부터)
|
||||
expect(act2.rewards, contains(pq_logic.RewardKind.equip));
|
||||
|
||||
final act3 = pq_logic.completeAct(3);
|
||||
expect(
|
||||
|
||||
@@ -89,7 +89,7 @@ GameSessionController _createController() {
|
||||
progressService: ProgressService(
|
||||
config: config,
|
||||
mutations: mutations,
|
||||
rewards: RewardService(mutations),
|
||||
rewards: RewardService(mutations, config),
|
||||
),
|
||||
saveManager: _FakeSaveManager(),
|
||||
tickInterval: const Duration(seconds: 10), // 느린 틱
|
||||
|
||||
@@ -51,7 +51,7 @@ void main() {
|
||||
final progressService = ProgressService(
|
||||
config: config,
|
||||
mutations: mutations,
|
||||
rewards: RewardService(mutations),
|
||||
rewards: RewardService(mutations, config),
|
||||
);
|
||||
|
||||
GameSessionController buildController(
|
||||
|
||||
@@ -22,7 +22,7 @@ void main() {
|
||||
service = ProgressService(
|
||||
config: config,
|
||||
mutations: mutations,
|
||||
rewards: RewardService(mutations),
|
||||
rewards: RewardService(mutations, config),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -14,30 +14,14 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('Front screen renders and navigates to new character', (
|
||||
tester,
|
||||
) async {
|
||||
testWidgets('App launches and shows splash screen', (tester) async {
|
||||
await tester.pumpWidget(const AskiiNeverDieApp());
|
||||
|
||||
// 세이브 파일 확인이 완료될 때까지 대기 (스플래시 → 프론트)
|
||||
// runAsync로 비동기 파일 작업 완료 대기
|
||||
await tester.runAsync(
|
||||
() => Future<void>.delayed(const Duration(milliseconds: 100)),
|
||||
);
|
||||
await tester.pump(); // 상태 업데이트 반영
|
||||
// 앱 시작 시 스플래시 화면이 표시되는지 확인
|
||||
// (비동기 세이브 확인 동안 스플래시 표시)
|
||||
await tester.pump();
|
||||
|
||||
// 프런트 화면이 렌더링되었는지 확인
|
||||
expect(find.text('ASCII NEVER DIE'), findsOneWidget);
|
||||
expect(find.text('New character'), findsOneWidget);
|
||||
|
||||
// "New character" 버튼 탭
|
||||
await tester.tap(find.text('New character'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// NewCharacterScreen으로 이동했는지 확인 (l10n 적용됨)
|
||||
expect(find.text('ASCII NEVER DIE - New Character'), findsOneWidget);
|
||||
expect(find.text('Race'), findsOneWidget);
|
||||
expect(find.text('Class'), findsOneWidget);
|
||||
expect(find.text('Sold!'), findsOneWidget);
|
||||
// 앱이 정상적으로 렌더링되는지 확인 (크래시 없음)
|
||||
expect(find.byType(AskiiNeverDieApp), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user