Compare commits

...

5 Commits

Author SHA1 Message Date
JiWoong Sul
d5c46ad04a refactor(ui): 화면 UI 정리
- front_screen: 프론트 화면 레이아웃 개선
- settings_screen: 설정 화면 간소화
- new_character_screen: 캐릭터 생성 화면 정리
2026-01-19 19:41:01 +09:00
JiWoong Sul
71740abe8f refactor(service): 서비스 로직 정리
- ad_service: 광고 서비스 코드 정리
- debug_settings_service: 디버그 설정 간소화
- stat_calculator: 스탯 계산 로직 정리
- character_roll_service: 캐릭터 롤 로직 수정
2026-01-19 19:40:54 +09:00
JiWoong Sul
0cccc17f1f refactor(model): 전투 상태 및 종족 데이터 수정
- combat_state: 전투 상태 모델 필드 추가
- race_traits: 종족 특성 정리
- race_data: 종족 데이터 업데이트
2026-01-19 19:40:48 +09:00
JiWoong Sul
5cccd28b77 refactor(engine): 전투 및 진행 로직 개선
- combat_tick_service: 전투 틱 처리 로직 확장
- progress_service: 진행 상태 처리 개선
- skill_service: 스킬 시스템 업데이트
- potion_service: 포션 처리 로직 수정
2026-01-19 19:40:42 +09:00
JiWoong Sul
109b4eb678 chore(android): 패키지명 변경 및 빌드 설정 업데이트
- 패키지명 com.example → com.naturebridgeai로 변경
- 기존 MainActivity.kt 삭제
- 새 패키지 경로에 MainActivity.kt 추가
2026-01-19 19:40:36 +09:00
16 changed files with 178 additions and 130 deletions

View File

@@ -6,7 +6,7 @@ plugins {
} }
android { android {
namespace = "com.example.asciineverdie" namespace = "com.naturebridgeai.asciineverdie"
compileSdk = flutter.compileSdkVersion compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion ndkVersion = flutter.ndkVersion
@@ -21,7 +21,7 @@ android {
defaultConfig { defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.example.asciineverdie" applicationId = "com.naturebridgeai.asciineverdie"
// You can update the following values to match your application needs. // You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config. // For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion minSdk = flutter.minSdkVersion

View File

@@ -1,4 +1,4 @@
package com.example.asciineverdie package com.naturebridgeai.asciineverdie
import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.android.FlutterActivity

View File

@@ -151,9 +151,9 @@ class RaceData {
}, },
passives: [ passives: [
PassiveAbility( PassiveAbility(
type: PassiveType.deathEquipmentPreserve, type: PassiveType.defenseBonus,
value: 1.0, value: 0.10,
description: '사망 시 장비 1개 유지', description: '방어력 +10%',
), ),
], ],
); );

View File

@@ -5,6 +5,8 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:google_mobile_ads/google_mobile_ads.dart'; import 'package:google_mobile_ads/google_mobile_ads.dart';
import 'package:asciineverdie/src/core/engine/iap_service.dart';
/// 광고 타입 /// 광고 타입
enum AdType { enum AdType {
/// 부활용 리워드 광고 (30초) /// 부활용 리워드 광고 (30초)
@@ -105,9 +107,6 @@ class AdService {
bool _isInitialized = false; bool _isInitialized = false;
/// 디버그 모드에서 광고 활성화 여부
bool _debugAdEnabled = true;
/// 로드된 리워드 광고 /// 로드된 리워드 광고
RewardedAd? _rewardedAd; RewardedAd? _rewardedAd;
@@ -144,23 +143,14 @@ class AdService {
} }
// =========================================================================== // ===========================================================================
// 디버그 설 // 광고 스킵 판
// =========================================================================== // ===========================================================================
/// 디버그 모드 광고 활성화 여부
bool get debugAdEnabled => _debugAdEnabled;
/// 디버그 모드 광고 토글
set debugAdEnabled(bool value) {
_debugAdEnabled = value;
debugPrint('[AdService] Debug ad enabled: $value');
}
/// 광고를 스킵할지 여부 /// 광고를 스킵할지 여부
/// ///
/// 스킵 조건: /// 스킵 조건:
/// - 비모바일 플랫폼 (macOS, Windows, Linux, Web) /// - 비모바일 플랫폼 (macOS, Windows, Linux, Web)
/// - 디버그 모드에서 광고 비활성화 /// - IAP로 광고 제거 구매 완료 (디버그 시뮬레이션 포함)
bool get _shouldSkipAd { bool get _shouldSkipAd {
// 웹에서는 항상 스킵 // 웹에서는 항상 스킵
if (kIsWeb) return true; if (kIsWeb) return true;
@@ -168,8 +158,9 @@ class AdService {
if (!Platform.isAndroid && !Platform.isIOS) { if (!Platform.isAndroid && !Platform.isIOS) {
return true; return true;
} }
// 디버그 모드에서 광고 비활성화 시 스킵 // IAP 광고 제거 구매 시 스킵 (디버그 시뮬레이션 포함)
return kDebugMode && !_debugAdEnabled; if (IAPService.instance.isAdRemovalPurchased) return true;
return false;
} }
// =========================================================================== // ===========================================================================
@@ -195,7 +186,9 @@ class AdService {
}, },
onAdFailedToLoad: (error) { onAdFailedToLoad: (error) {
_isLoadingRewardedAd = false; _isLoadingRewardedAd = false;
debugPrint('[AdService] Rewarded ad failed to load: ${error.message}'); debugPrint(
'[AdService] Rewarded ad failed to load: ${error.message}',
);
}, },
), ),
); );
@@ -249,7 +242,9 @@ class AdService {
_loadRewardedAd(); // 다음 광고 미리 로드 _loadRewardedAd(); // 다음 광고 미리 로드
// 보상 수령 여부에 따라 결과 반환 // 보상 수령 여부에 따라 결과 반환
if (!completer.isCompleted) { if (!completer.isCompleted) {
completer.complete(rewarded ? AdResult.completed : AdResult.cancelled); completer.complete(
rewarded ? AdResult.completed : AdResult.cancelled,
);
} }
}, },
onAdFailedToShowFullScreenContent: (ad, error) { onAdFailedToShowFullScreenContent: (ad, error) {

View File

@@ -143,11 +143,10 @@ class CharacterRollService {
_rollsRemaining--; _rollsRemaining--;
_saveRollsRemaining(); _saveRollsRemaining();
// 무료 유저: 새 굴리기마다 되돌리기 기회 1회 부여 (광고 시청 필요) // 매 굴림마다 되돌리기 횟수 리셋
// 유료 유저: 세션당 최대 횟수 유지 // - 유료 유저: 3회
if (!_isPaidUser && _undoRemaining < maxUndoFreeUser) { // - 무료 유저: 1회 (광고 시청 필요)
_undoRemaining = maxUndoFreeUser; _undoRemaining = _isPaidUser ? maxUndoPaidUser : maxUndoFreeUser;
}
debugPrint('[CharacterRollService] Rolled: remaining=$_rollsRemaining, ' debugPrint('[CharacterRollService] Rolled: remaining=$_rollsRemaining, '
'history=${_rollHistory.length}, undo=$_undoRemaining'); 'history=${_rollHistory.length}, undo=$_undoRemaining');

View File

@@ -1,5 +1,7 @@
import 'package:asciineverdie/data/class_data.dart';
import 'package:asciineverdie/data/skill_data.dart'; import 'package:asciineverdie/data/skill_data.dart';
import 'package:asciineverdie/src/core/engine/combat_calculator.dart'; import 'package:asciineverdie/src/core/engine/combat_calculator.dart';
import 'package:asciineverdie/src/core/model/class_traits.dart';
import 'package:asciineverdie/src/core/engine/potion_service.dart'; import 'package:asciineverdie/src/core/engine/potion_service.dart';
import 'package:asciineverdie/src/core/engine/skill_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_event.dart';
@@ -94,6 +96,17 @@ class CombatTickService {
totalDamageDealt = dotResult.totalDamageDealt; totalDamageDealt = dotResult.totalDamageDealt;
newEvents.addAll(dotResult.events); newEvents.addAll(dotResult.events);
// 클래스 패시브 조회 (healingBonus, firstStrikeBonus, multiAttack)
final klass = ClassData.findById(state.traits.classId);
final healingBonus =
klass?.getPassiveValue(ClassPassiveType.healingBonus) ?? 0.0;
final healingMultiplier = 1.0 + healingBonus;
final firstStrikeBonus =
klass?.getPassiveValue(ClassPassiveType.firstStrikeBonus) ?? 0.0;
final hasMultiAttack =
klass?.hasPassive(ClassPassiveType.multiAttack) ?? false;
var isFirstPlayerAttack = combat.isFirstPlayerAttack;
// 긴급 물약 자동 사용 (HP < 30% 또는 MP < 50%) // 긴급 물약 자동 사용 (HP < 30% 또는 MP < 50%)
final potionResult = _tryEmergencyPotion( final potionResult = _tryEmergencyPotion(
playerStats: playerStats, playerStats: playerStats,
@@ -102,6 +115,7 @@ class CombatTickService {
playerLevel: state.traits.level, playerLevel: state.traits.level,
timestamp: timestamp, timestamp: timestamp,
potionService: potionService, potionService: potionService,
healingMultiplier: healingMultiplier,
); );
if (potionResult != null) { if (potionResult != null) {
playerStats = potionResult.playerStats; playerStats = potionResult.playerStats;
@@ -123,6 +137,10 @@ class CombatTickService {
timestamp: timestamp, timestamp: timestamp,
calculator: calculator, calculator: calculator,
skillService: skillService, skillService: skillService,
isFirstPlayerAttack: isFirstPlayerAttack,
firstStrikeBonus: firstStrikeBonus > 0 ? firstStrikeBonus : 1.0,
hasMultiAttack: hasMultiAttack,
healingMultiplier: healingMultiplier,
); );
playerStats = attackResult.playerStats; playerStats = attackResult.playerStats;
@@ -132,6 +150,7 @@ class CombatTickService {
activeDebuffs = attackResult.activeDebuffs; activeDebuffs = attackResult.activeDebuffs;
totalDamageDealt = attackResult.totalDamageDealt; totalDamageDealt = attackResult.totalDamageDealt;
newEvents.addAll(attackResult.events); newEvents.addAll(attackResult.events);
isFirstPlayerAttack = attackResult.isFirstPlayerAttack;
playerAccumulator -= playerStats.attackDelayMs; playerAccumulator -= playerStats.attackDelayMs;
turnsElapsed++; turnsElapsed++;
@@ -178,6 +197,7 @@ class CombatTickService {
activeDoTs: activeDoTs, activeDoTs: activeDoTs,
lastPotionUsedMs: lastPotionUsedMs, lastPotionUsedMs: lastPotionUsedMs,
activeDebuffs: activeDebuffs, activeDebuffs: activeDebuffs,
isFirstPlayerAttack: isFirstPlayerAttack,
), ),
skillSystem: updatedSkillSystem, skillSystem: updatedSkillSystem,
potionInventory: updatedPotionInventory, potionInventory: updatedPotionInventory,
@@ -259,6 +279,7 @@ class CombatTickService {
required int playerLevel, required int playerLevel,
required int timestamp, required int timestamp,
required PotionService potionService, required PotionService potionService,
double healingMultiplier = 1.0,
}) { }) {
// 글로벌 쿨타임 체크 // 글로벌 쿨타임 체크
if (timestamp - lastPotionUsedMs < PotionService.globalPotionCooldownMs) { if (timestamp - lastPotionUsedMs < PotionService.globalPotionCooldownMs) {
@@ -281,6 +302,7 @@ class CombatTickService {
maxHp: playerStats.hpMax, maxHp: playerStats.hpMax,
currentMp: playerStats.mpCurrent, currentMp: playerStats.mpCurrent,
maxMp: playerStats.mpMax, maxMp: playerStats.mpMax,
healingMultiplier: healingMultiplier,
); );
if (result.success) { if (result.success) {
@@ -316,6 +338,7 @@ class CombatTickService {
maxHp: playerStats.hpMax, maxHp: playerStats.hpMax,
currentMp: playerStats.mpCurrent, currentMp: playerStats.mpCurrent,
maxMp: playerStats.mpMax, maxMp: playerStats.mpMax,
healingMultiplier: healingMultiplier,
); );
if (result.success) { if (result.success) {
@@ -347,6 +370,7 @@ class CombatTickService {
List<ActiveBuff> activeDebuffs, List<ActiveBuff> activeDebuffs,
int totalDamageDealt, int totalDamageDealt,
List<CombatEvent> events, List<CombatEvent> events,
bool isFirstPlayerAttack,
}) _processPlayerAttack({ }) _processPlayerAttack({
required GameState state, required GameState state,
required CombatStats playerStats, required CombatStats playerStats,
@@ -358,6 +382,10 @@ class CombatTickService {
required int timestamp, required int timestamp,
required CombatCalculator calculator, required CombatCalculator calculator,
required SkillService skillService, required SkillService skillService,
required bool isFirstPlayerAttack,
required double firstStrikeBonus,
required bool hasMultiAttack,
double healingMultiplier = 1.0,
}) { }) {
final events = <CombatEvent>[]; final events = <CombatEvent>[];
var newPlayerStats = playerStats; var newPlayerStats = playerStats;
@@ -442,6 +470,7 @@ class CombatTickService {
skill: selectedSkill, skill: selectedSkill,
player: newPlayerStats, player: newPlayerStats,
skillSystem: newSkillSystem, skillSystem: newSkillSystem,
healingMultiplier: healingMultiplier,
); );
newPlayerStats = skillResult.updatedPlayer; newPlayerStats = skillResult.updatedPlayer;
newSkillSystem = skillResult.updatedSkillSystem.startGlobalCooldown(); newSkillSystem = skillResult.updatedSkillSystem.startGlobalCooldown();
@@ -499,7 +528,22 @@ class CombatTickService {
defender: newMonsterStats, defender: newMonsterStats,
); );
newMonsterStats = attackResult.updatedDefender; newMonsterStats = attackResult.updatedDefender;
newTotalDamageDealt += attackResult.result.damage;
// 첫 공격 배율 적용 (예: Pointer Assassin 1.5배)
var damage = attackResult.result.damage;
if (isFirstPlayerAttack && firstStrikeBonus > 1.0) {
damage = (damage * firstStrikeBonus).round();
// 첫 공격 배율이 적용된 데미지로 몬스터 HP 재계산
final extraDamage = damage - attackResult.result.damage;
if (extraDamage > 0) {
final newHp = (newMonsterStats.hpCurrent - extraDamage).clamp(
0,
newMonsterStats.hpMax,
);
newMonsterStats = newMonsterStats.copyWith(hpCurrent: newHp);
}
}
newTotalDamageDealt += damage;
final result = attackResult.result; final result = attackResult.result;
if (result.isEvaded) { if (result.isEvaded) {
@@ -513,13 +557,35 @@ class CombatTickService {
events.add( events.add(
CombatEvent.playerAttack( CombatEvent.playerAttack(
timestamp: timestamp, timestamp: timestamp,
damage: result.damage, damage: damage,
targetName: newMonsterStats.name, targetName: newMonsterStats.name,
isCritical: result.isCritical, isCritical: result.isCritical,
attackDelayMs: newPlayerStats.attackDelayMs, attackDelayMs: newPlayerStats.attackDelayMs,
), ),
); );
} }
// 연속 공격 (Refactor Monk 패시브) - 30% 확률로 추가 공격
if (hasMultiAttack && newMonsterStats.isAlive && rng.nextDouble() < 0.3) {
final extraAttack = calculator.playerAttackMonster(
attacker: newPlayerStats,
defender: newMonsterStats,
);
newMonsterStats = extraAttack.updatedDefender;
newTotalDamageDealt += extraAttack.result.damage;
if (!extraAttack.result.isEvaded) {
events.add(
CombatEvent.playerAttack(
timestamp: timestamp,
damage: extraAttack.result.damage,
targetName: newMonsterStats.name,
isCritical: extraAttack.result.isCritical,
attackDelayMs: newPlayerStats.attackDelayMs,
),
);
}
}
} }
return ( return (
@@ -530,6 +596,7 @@ class CombatTickService {
activeDebuffs: newActiveBuffs, activeDebuffs: newActiveBuffs,
totalDamageDealt: newTotalDamageDealt, totalDamageDealt: newTotalDamageDealt,
events: events, events: events,
isFirstPlayerAttack: false, // 첫 공격 이후에는 false
); );
} }

View File

@@ -1,7 +1,6 @@
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:asciineverdie/src/core/engine/ad_service.dart';
import 'package:asciineverdie/src/core/engine/iap_service.dart'; import 'package:asciineverdie/src/core/engine/iap_service.dart';
/// 디버그 설정 서비스 (Phase 8) /// 디버그 설정 서비스 (Phase 8)
@@ -23,7 +22,6 @@ class DebugSettingsService {
// 상수 // 상수
// =========================================================================== // ===========================================================================
static const String _keyAdEnabled = 'debug_ad_enabled';
static const String _keyIapSimulated = 'debug_iap_simulated'; static const String _keyIapSimulated = 'debug_iap_simulated';
static const String _keyOfflineHours = 'debug_offline_hours'; static const String _keyOfflineHours = 'debug_offline_hours';
@@ -36,9 +34,6 @@ class DebugSettingsService {
bool _isInitialized = false; bool _isInitialized = false;
/// 광고 활성화 여부 (디버그 모드 전용)
bool _adEnabled = true;
/// IAP 구매 시뮬레이션 여부 (디버그 모드 전용) /// IAP 구매 시뮬레이션 여부 (디버그 모드 전용)
bool _iapSimulated = false; bool _iapSimulated = false;
@@ -58,7 +53,6 @@ class DebugSettingsService {
} }
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
_adEnabled = prefs.getBool(_keyAdEnabled) ?? true;
_iapSimulated = prefs.getBool(_keyIapSimulated) ?? false; _iapSimulated = prefs.getBool(_keyIapSimulated) ?? false;
_offlineHours = prefs.getInt(_keyOfflineHours) ?? 0; _offlineHours = prefs.getInt(_keyOfflineHours) ?? 0;
@@ -66,13 +60,14 @@ class DebugSettingsService {
_syncToServices(); _syncToServices();
_isInitialized = true; _isInitialized = true;
debugPrint('[DebugSettings] Initialized: ad=$_adEnabled, ' debugPrint(
'iap=$_iapSimulated, offline=$_offlineHours'); '[DebugSettings] Initialized: '
'iap=$_iapSimulated, offline=$_offlineHours',
);
} }
/// 설정을 다른 서비스에 동기화 /// 설정을 다른 서비스에 동기화
void _syncToServices() { void _syncToServices() {
AdService.instance.debugAdEnabled = _adEnabled;
IAPService.instance.debugIAPSimulated = _iapSimulated; IAPService.instance.debugIAPSimulated = _iapSimulated;
} }
@@ -83,29 +78,6 @@ class DebugSettingsService {
/// 디버그 모드 활성화 여부 /// 디버그 모드 활성화 여부
bool get isDebugMode => kDebugMode; bool get isDebugMode => kDebugMode;
// ===========================================================================
// 광고 설정
// ===========================================================================
/// 광고 활성화 여부
///
/// - true: 실제 광고 표시
/// - false: 광고 버튼 클릭 시 바로 보상 지급
bool get adEnabled => _adEnabled;
/// 광고 활성화 토글
Future<void> setAdEnabled(bool value) async {
if (!kDebugMode) return;
_adEnabled = value;
AdService.instance.debugAdEnabled = value;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_keyAdEnabled, value);
debugPrint('[DebugSettings] Ad enabled: $value');
}
// =========================================================================== // ===========================================================================
// IAP 설정 // IAP 설정
// =========================================================================== // ===========================================================================
@@ -171,7 +143,6 @@ class DebugSettingsService {
Future<void> resetAll() async { Future<void> resetAll() async {
if (!kDebugMode) return; if (!kDebugMode) return;
await setAdEnabled(true);
await setIapSimulated(false); await setIapSimulated(false);
await setOfflineHours(0); await setOfflineHours(0);

View File

@@ -52,6 +52,7 @@ class PotionService {
/// [maxHp] 최대 HP /// [maxHp] 최대 HP
/// [currentMp] 현재 MP /// [currentMp] 현재 MP
/// [maxMp] 최대 MP /// [maxMp] 최대 MP
/// [healingMultiplier] 회복력 배율 (기본 1.0, 클래스 패시브 적용)
PotionUseResult usePotion({ PotionUseResult usePotion({
required String potionId, required String potionId,
required PotionInventory inventory, required PotionInventory inventory,
@@ -59,6 +60,7 @@ class PotionService {
required int maxHp, required int maxHp,
required int currentMp, required int currentMp,
required int maxMp, required int maxMp,
double healingMultiplier = 1.0,
}) { }) {
final (canUse, failReason) = canUsePotion(potionId, inventory); final (canUse, failReason) = canUsePotion(potionId, inventory);
if (!canUse) { if (!canUse) {
@@ -71,11 +73,15 @@ class PotionService {
int newMp = currentMp; int newMp = currentMp;
if (potion.isHpPotion) { if (potion.isHpPotion) {
healedAmount = potion.calculateHeal(maxHp); // 회복력 보너스 적용 (예: Debugger Paladin +10%)
final baseHeal = potion.calculateHeal(maxHp);
healedAmount = (baseHeal * healingMultiplier).round();
newHp = (currentHp + healedAmount).clamp(0, maxHp); newHp = (currentHp + healedAmount).clamp(0, maxHp);
healedAmount = newHp - currentHp; // 실제 회복량 healedAmount = newHp - currentHp; // 실제 회복량
} else if (potion.isMpPotion) { } else if (potion.isMpPotion) {
healedAmount = potion.calculateHeal(maxMp); // MP 물약에도 회복력 보너스 적용
final baseHeal = potion.calculateHeal(maxMp);
healedAmount = (baseHeal * healingMultiplier).round();
newMp = (currentMp + healedAmount).clamp(0, maxMp); newMp = (currentMp + healedAmount).clamp(0, maxMp);
healedAmount = newMp - currentMp; // 실제 회복량 healedAmount = newMp - currentMp; // 실제 회복량
} }

View File

@@ -1,6 +1,9 @@
import 'dart:math' as math; import 'dart:math' as math;
import 'package:asciineverdie/data/class_data.dart';
import 'package:asciineverdie/data/game_text_l10n.dart' as l10n; import 'package:asciineverdie/data/game_text_l10n.dart' as l10n;
import 'package:asciineverdie/data/race_data.dart';
import 'package:asciineverdie/src/core/model/class_traits.dart';
import 'package:asciineverdie/src/core/animation/monster_size.dart'; import 'package:asciineverdie/src/core/animation/monster_size.dart';
import 'package:asciineverdie/src/core/engine/act_progression_service.dart'; import 'package:asciineverdie/src/core/engine/act_progression_service.dart';
import 'package:asciineverdie/src/core/engine/combat_calculator.dart'; import 'package:asciineverdie/src/core/engine/combat_calculator.dart';
@@ -273,10 +276,21 @@ class ProgressService {
final remainingHp = combat.playerStats.hpCurrent; final remainingHp = combat.playerStats.hpCurrent;
final maxHp = combat.playerStats.hpMax; final maxHp = combat.playerStats.hpMax;
// 전투 승리 시 HP 회복 (50% + CON/2) // 전투 승리 시 HP 회복 (50% + CON/2 + 클래스 패시브)
// 아이들 게임 특성상 전투 사이 HP가 회복되어야 지속 플레이 가능 // 아이들 게임 특성상 전투 사이 HP가 회복되어야 지속 플레이 가능
final conBonus = nextState.stats.con ~/ 2; final conBonus = nextState.stats.con ~/ 2;
final healAmount = (maxHp * 0.5).round() + conBonus; var healAmount = (maxHp * 0.5).round() + conBonus;
// 클래스 패시브: 전투 후 HP 회복 (예: Garbage Collector +5%)
final klass = ClassData.findById(nextState.traits.classId);
if (klass != null) {
final postCombatHealRate =
klass.getPassiveValue(ClassPassiveType.postCombatHeal);
if (postCombatHealRate > 0) {
healAmount += (maxHp * postCombatHealRate).round();
}
}
final newHp = (remainingHp + healAmount).clamp(0, maxHp); final newHp = (remainingHp + healAmount).clamp(0, maxHp);
nextState = nextState.copyWith( nextState = nextState.copyWith(
@@ -384,7 +398,11 @@ class ProgressService {
// Gain XP / level up (몬스터 경험치 기반) // Gain XP / level up (몬스터 경험치 기반)
// 최대 레벨(100) 제한: 100레벨에서는 더 이상 레벨업하지 않음 // 최대 레벨(100) 제한: 100레벨에서는 더 이상 레벨업하지 않음
if (gain && nextState.traits.level < 100 && monsterExpReward > 0) { if (gain && nextState.traits.level < 100 && monsterExpReward > 0) {
final newExpPos = progress.exp.position + monsterExpReward; // 종족 경험치 배율 적용 (예: Byte Human +5%, Callback Seraph +3%)
final race = RaceData.findById(nextState.traits.raceId);
final expMultiplier = race?.expMultiplier ?? 1.0;
final adjustedExp = (monsterExpReward * expMultiplier).round();
final newExpPos = progress.exp.position + adjustedExp;
// 레벨업 체크 (경험치가 필요량 이상일 때) // 레벨업 체크 (경험치가 필요량 이상일 때)
if (newExpPos >= progress.exp.max) { if (newExpPos >= progress.exp.max) {

View File

@@ -112,6 +112,8 @@ class SkillService {
} }
/// 회복 스킬 사용 /// 회복 스킬 사용
///
/// [healingMultiplier] 회복력 배율 (기본 1.0, 클래스 패시브 적용)
({ ({
SkillUseResult result, SkillUseResult result,
CombatStats updatedPlayer, CombatStats updatedPlayer,
@@ -121,6 +123,7 @@ class SkillService {
required Skill skill, required Skill skill,
required CombatStats player, required CombatStats player,
required SkillSystemState skillSystem, required SkillSystemState skillSystem,
double healingMultiplier = 1.0,
}) { }) {
// 회복량 계산 // 회복량 계산
int healAmount = skill.healAmount; int healAmount = skill.healAmount;
@@ -128,6 +131,9 @@ class SkillService {
healAmount += (player.hpMax * skill.healPercent).round(); healAmount += (player.hpMax * skill.healPercent).round();
} }
// 회복력 보너스 적용 (예: Debugger Paladin +10%, Exception Handler +15%)
healAmount = (healAmount * healingMultiplier).round();
// HP 회복 // HP 회복
var updatedPlayer = player.applyHeal(healAmount); var updatedPlayer = player.applyHeal(healAmount);

View File

@@ -187,17 +187,6 @@ class StatCalculator {
return bonus > 0 ? bonus : 1.0; return bonus > 0 ? bonus : 1.0;
} }
/// 사망 시 보존할 장비 개수 (Coredump Undead 패시브)
///
/// [race] 종족 특성
/// Returns: 보존 장비 개수
int calculateDeathEquipmentPreserve(RaceTraits race) {
if (race.hasPassive(PassiveType.deathEquipmentPreserve)) {
return race.getPassiveValue(PassiveType.deathEquipmentPreserve).round();
}
return 0;
}
/// 연속 공격 가능 여부 (Refactor Monk 패시브) /// 연속 공격 가능 여부 (Refactor Monk 패시브)
bool hasMultiAttack(ClassTraits klass) { bool hasMultiAttack(ClassTraits klass) {
return klass.hasPassive(ClassPassiveType.multiAttack); return klass.hasPassive(ClassPassiveType.multiAttack);

View File

@@ -21,6 +21,7 @@ class CombatState {
this.activeDoTs = const [], this.activeDoTs = const [],
this.lastPotionUsedMs = 0, this.lastPotionUsedMs = 0,
this.activeDebuffs = const [], this.activeDebuffs = const [],
this.isFirstPlayerAttack = true,
}); });
/// 플레이어 전투 스탯 /// 플레이어 전투 스탯
@@ -59,6 +60,9 @@ class CombatState {
/// 몬스터에 적용된 활성 디버프 목록 /// 몬스터에 적용된 활성 디버프 목록
final List<ActiveBuff> activeDebuffs; final List<ActiveBuff> activeDebuffs;
/// 첫 번째 플레이어 공격 여부 (firstStrikeBonus 적용용)
final bool isFirstPlayerAttack;
// ============================================================================ // ============================================================================
// 유틸리티 // 유틸리티
// ============================================================================ // ============================================================================
@@ -124,6 +128,7 @@ class CombatState {
List<DotEffect>? activeDoTs, List<DotEffect>? activeDoTs,
int? lastPotionUsedMs, int? lastPotionUsedMs,
List<ActiveBuff>? activeDebuffs, List<ActiveBuff>? activeDebuffs,
bool? isFirstPlayerAttack,
}) { }) {
return CombatState( return CombatState(
playerStats: playerStats ?? this.playerStats, playerStats: playerStats ?? this.playerStats,
@@ -140,6 +145,7 @@ class CombatState {
activeDoTs: activeDoTs ?? this.activeDoTs, activeDoTs: activeDoTs ?? this.activeDoTs,
lastPotionUsedMs: lastPotionUsedMs ?? this.lastPotionUsedMs, lastPotionUsedMs: lastPotionUsedMs ?? this.lastPotionUsedMs,
activeDebuffs: activeDebuffs ?? this.activeDebuffs, activeDebuffs: activeDebuffs ?? this.activeDebuffs,
isFirstPlayerAttack: isFirstPlayerAttack ?? this.isFirstPlayerAttack,
); );
} }

View File

@@ -20,9 +20,6 @@ enum PassiveType {
/// MP 배율 보너스 /// MP 배율 보너스
mpBonus, mpBonus,
/// 사망 시 장비 보존
deathEquipmentPreserve,
} }
/// 패시브 능력 (passive ability) /// 패시브 능력 (passive ability)

View File

@@ -321,8 +321,8 @@ class _ActionButtons extends StatelessWidget {
onPressed: onSettings, onPressed: onSettings,
isPrimary: false, isPrimary: false,
), ),
// IAP 구매 (광고 제거) - 스토어 사용 가능하고 미구매 상태일 때만 표시 // IAP 구매 (광고 제거) - 스토어 사용 가능할 때 표시 (구매 완료 시 비활성화)
if (removeAdsPrice != null && !isAdRemovalPurchased) ...[ if (removeAdsPrice != null) ...[
const SizedBox(height: 20), const SizedBox(height: 20),
const Divider(color: RetroColors.panelBorderInner, height: 1), const Divider(color: RetroColors.panelBorderInner, height: 1),
const SizedBox(height: 12), const SizedBox(height: 12),
@@ -330,13 +330,12 @@ class _ActionButtons extends StatelessWidget {
price: removeAdsPrice!, price: removeAdsPrice!,
onPurchase: onPurchaseRemoveAds, onPurchase: onPurchaseRemoveAds,
onRestore: onRestorePurchase, onRestore: onRestorePurchase,
enabled: !isAdRemovalPurchased,
), ),
], ],
// 이미 구매된 경우 표시 // 이미 구매된 경우 배지 표시
if (isAdRemovalPurchased) ...[ if (isAdRemovalPurchased) ...[
const SizedBox(height: 20), const SizedBox(height: 8),
const Divider(color: RetroColors.panelBorderInner, height: 1),
const SizedBox(height: 12),
_PurchasedBadge(), _PurchasedBadge(),
], ],
], ],
@@ -412,11 +411,13 @@ class _IapPurchaseButton extends StatelessWidget {
required this.price, required this.price,
this.onPurchase, this.onPurchase,
this.onRestore, this.onRestore,
this.enabled = true,
}); });
final String price; final String price;
final VoidCallback? onPurchase; final VoidCallback? onPurchase;
final VoidCallback? onRestore; final VoidCallback? onRestore;
final bool enabled;
void _showPurchaseDialog(BuildContext context) { void _showPurchaseDialog(BuildContext context) {
showDialog<void>( showDialog<void>(
@@ -433,23 +434,33 @@ class _IapPurchaseButton extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// 비활성화 상태의 색상
final buttonColor = enabled ? RetroColors.gold : RetroColors.textDisabled;
final bgGradient = enabled
? const LinearGradient(
colors: [Color(0xFF4A3B2A), Color(0xFF3D2E1F)],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
)
: const LinearGradient(
colors: [Color(0xFF3A3A3A), Color(0xFF2A2A2A)],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
);
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
// 구매 버튼 (클릭 시 팝업) // 구매 버튼 (클릭 시 팝업)
Container( Container(
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: const LinearGradient( gradient: bgGradient,
colors: [Color(0xFF4A3B2A), Color(0xFF3D2E1F)], border: Border.all(color: buttonColor, width: 2),
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
border: Border.all(color: RetroColors.gold, width: 2),
), ),
child: Material( child: Material(
color: Colors.transparent, color: Colors.transparent,
child: InkWell( child: InkWell(
onTap: () => _showPurchaseDialog(context), onTap: enabled ? () => _showPurchaseDialog(context) : null,
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 16, horizontal: 16,
@@ -457,7 +468,7 @@ class _IapPurchaseButton extends StatelessWidget {
), ),
child: Row( child: Row(
children: [ children: [
const Icon(Icons.block, color: RetroColors.gold, size: 24), Icon(Icons.block, color: buttonColor, size: 24),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
child: Column( child: Column(
@@ -465,10 +476,10 @@ class _IapPurchaseButton extends StatelessWidget {
children: [ children: [
Text( Text(
game_l10n.iapRemoveAds, game_l10n.iapRemoveAds,
style: const TextStyle( style: TextStyle(
fontFamily: 'PressStart2P', fontFamily: 'PressStart2P',
fontSize: 12, fontSize: 12,
color: RetroColors.gold, color: buttonColor,
), ),
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
@@ -484,11 +495,7 @@ class _IapPurchaseButton extends StatelessWidget {
), ),
), ),
// 화살표 아이콘 (상세 보기) // 화살표 아이콘 (상세 보기)
const Icon( Icon(Icons.arrow_forward_ios, color: buttonColor, size: 16),
Icons.arrow_forward_ios,
color: RetroColors.gold,
size: 16,
),
], ],
), ),
), ),
@@ -499,7 +506,7 @@ class _IapPurchaseButton extends StatelessWidget {
// 복원 버튼 // 복원 버튼
Center( Center(
child: TextButton( child: TextButton(
onPressed: onRestore, onPressed: enabled ? onRestore : null,
child: Text( child: Text(
game_l10n.iapRestorePurchase, game_l10n.iapRestorePurchase,
style: const TextStyle( style: const TextStyle(
@@ -569,7 +576,10 @@ class _IapPurchaseDialog extends StatelessWidget {
const SizedBox(height: 8), const SizedBox(height: 8),
_BenefitItem(icon: Icons.speed, text: game_l10n.iapBenefit5), _BenefitItem(icon: Icons.speed, text: game_l10n.iapBenefit5),
const SizedBox(height: 8), const SizedBox(height: 8),
_BenefitItem(icon: Icons.inventory_2, text: game_l10n.iapBenefit6), _BenefitItem(
icon: Icons.inventory_2,
text: game_l10n.iapBenefit6,
),
const SizedBox(height: 20), const SizedBox(height: 20),
// 가격 + 구매 버튼 // 가격 + 구매 버튼
Container( Container(

View File

@@ -803,7 +803,6 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
PassiveType.magicDamageBonus => game_l10n.passiveMagicBonus(percent), PassiveType.magicDamageBonus => game_l10n.passiveMagicBonus(percent),
PassiveType.criticalBonus => game_l10n.passiveCritBonus(percent), PassiveType.criticalBonus => game_l10n.passiveCritBonus(percent),
PassiveType.expBonus => passive.description, PassiveType.expBonus => passive.description,
PassiveType.deathEquipmentPreserve => passive.description,
}; };
} }

View File

@@ -64,7 +64,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
bool _isLoading = true; bool _isLoading = true;
// 디버그 설정 상태 (Phase 8) // 디버그 설정 상태 (Phase 8)
bool _debugAdEnabled = true;
bool _debugIapSimulated = false; bool _debugIapSimulated = false;
int _debugOfflineHours = 0; int _debugOfflineHours = 0;
@@ -80,7 +79,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
// 디버그 설정 로드 (Phase 8) // 디버그 설정 로드 (Phase 8)
final debugSettings = DebugSettingsService.instance; final debugSettings = DebugSettingsService.instance;
final adEnabled = debugSettings.adEnabled;
final iapSimulated = debugSettings.iapSimulated; final iapSimulated = debugSettings.iapSimulated;
final offlineHours = debugSettings.offlineHours; final offlineHours = debugSettings.offlineHours;
@@ -88,7 +86,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
setState(() { setState(() {
_bgmVolume = bgm; _bgmVolume = bgm;
_sfxVolume = sfx; _sfxVolume = sfx;
_debugAdEnabled = adEnabled;
_debugIapSimulated = iapSimulated; _debugIapSimulated = iapSimulated;
_debugOfflineHours = offlineHours; _debugOfflineHours = offlineHours;
_isLoading = false; _isLoading = false;
@@ -304,7 +301,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
// 헤더 // 헤더
Row( Row(
children: [ children: [
Icon(Icons.bug_report, color: RetroColors.hpOf(context), size: 16), Icon(
Icons.bug_report,
color: RetroColors.hpOf(context),
size: 16,
),
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
'DEVELOPER TOOLS', 'DEVELOPER TOOLS',
@@ -318,19 +319,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
// 광고 ON/OFF 토글
_RetroDebugToggle(
icon: Icons.ad_units,
label: 'ADS ENABLED',
description: 'OFF: 광고 버튼 클릭 시 바로 보상',
value: _debugAdEnabled,
onChanged: (value) async {
await DebugSettingsService.instance.setAdEnabled(value);
setState(() => _debugAdEnabled = value);
},
),
const SizedBox(height: 12),
// IAP 시뮬레이션 토글 // IAP 시뮬레이션 토글
_RetroDebugToggle( _RetroDebugToggle(
icon: Icons.shopping_cart, icon: Icons.shopping_cart,
@@ -448,7 +436,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
context: context, context: context,
builder: (context) => _RetroConfirmDialog( builder: (context) => _RetroConfirmDialog(
title: 'CREATE TEST CHARACTER?', title: 'CREATE TEST CHARACTER?',
message: '현재 캐릭터가 레벨 100으로 변환되어\n' message:
'현재 캐릭터가 레벨 100으로 변환되어\n'
'명예의 전당에 등록됩니다.\n\n' '명예의 전당에 등록됩니다.\n\n'
'⚠️ 현재 세이브 파일이 삭제됩니다.\n' '⚠️ 현재 세이브 파일이 삭제됩니다.\n'
'이 작업은 되돌릴 수 없습니다.', '이 작업은 되돌릴 수 없습니다.',
@@ -480,11 +469,7 @@ class _RetroSectionTitle extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Row( return Row(
children: [ children: [
Container( Container(width: 4, height: 14, color: RetroColors.goldOf(context)),
width: 4,
height: 14,
color: RetroColors.goldOf(context),
),
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
title.toUpperCase(), title.toUpperCase(),