feat(model): 스킬 티어 및 파워 스코어 시스템 추가

- Skill 클래스에 tier 필드 추가 (1~5, 높을수록 강함)
- 타입별 powerScore 동적 계산 로직 구현
- isStrongerThan() 메서드로 스킬 강도 비교 지원
- SkillFailReason에 onGlobalCooldown 추가
- SkillSlots 클래스 신규 추가 (타입별 슬롯 제한)
This commit is contained in:
JiWoong Sul
2026-01-14 23:03:51 +09:00
parent f9a4ae105a
commit 2621942ced
2 changed files with 357 additions and 0 deletions

View File

@@ -117,6 +117,7 @@ class Skill {
required this.mpCost,
required this.cooldownMs,
required this.power,
this.tier = 1,
this.damageMultiplier = 1.0,
this.healAmount = 0,
this.healPercent = 0.0,
@@ -133,6 +134,9 @@ class Skill {
this.mpHealAmount = 0,
});
/// 스킬 티어 (1~5, 높을수록 강함)
final int tier;
/// 스킬 ID
final String id;
@@ -213,6 +217,79 @@ class Skill {
if (type != SkillType.attack || damageMultiplier <= 0) return 0;
return damageMultiplier / mpCost;
}
/// 스킬 파워 점수 (동적 계산, 같은 티어 내 비교용)
///
/// 타입별 다른 공식:
/// - 공격: DPS 기반
/// - 회복: HPS 기반
/// - 버프/디버프: 효과 강도 × 지속시간 / 쿨타임
double get powerScore {
final cdSec = cooldownMs / 1000;
if (cdSec <= 0) return 0;
return switch (type) {
SkillType.attack => _attackPowerScore(cdSec),
SkillType.heal => _healPowerScore(cdSec),
SkillType.buff => _buffPowerScore(cdSec),
SkillType.debuff => _debuffPowerScore(cdSec),
};
}
double _attackPowerScore(double cdSec) {
// DOT 스킬
if (isDot &&
baseDotDamage != null &&
baseDotDurationMs != null &&
baseDotTickMs != null &&
baseDotTickMs! > 0) {
final ticks = baseDotDurationMs! / baseDotTickMs!;
return (baseDotDamage! * ticks) / cdSec;
}
// 즉발 공격: power × 배율 × 타수 / 쿨타임
var score = power * damageMultiplier * hitCount / cdSec;
score *= (1 + lifestealPercent); // 흡혈 보너스
score *= (1 + targetDefReduction); // 방감 보너스
return score;
}
double _healPowerScore(double cdSec) {
// 고정 회복 + %회복(1000HP 기준) + MP회복
final totalHeal = healAmount + (healPercent * 1000) + mpHealAmount;
var score = totalHeal / cdSec;
if (buff != null) score *= 1.2; // 추가 버프 보너스
return score;
}
double _buffPowerScore(double cdSec) {
if (buff == null) return 0;
final b = buff!;
final strength =
b.atkModifier.abs() +
b.defModifier.abs() +
b.criRateModifier.abs() +
b.evasionModifier.abs();
return strength * (b.durationMs / 1000) / cdSec * 100;
}
double _debuffPowerScore(double cdSec) {
if (buff == null) return 0;
final b = buff!;
final strength = b.atkModifier.abs() + b.defModifier.abs();
return strength * (b.durationMs / 1000) / cdSec * 100;
}
/// 다른 스킬과 비교하여 이 스킬이 더 강한지 판단
///
/// 1. 티어가 다르면 티어로 판단
/// 2. 티어가 같으면 파워 점수로 판단
bool isStrongerThan(Skill other) {
if (tier != other.tier) {
return tier > other.tier;
}
return powerScore > other.powerScore;
}
}
/// 스킬 사용 상태 (쿨타임 추적)
@@ -347,6 +424,9 @@ enum SkillFailReason {
/// 쿨타임 중
onCooldown,
/// 글로벌 쿨타임(GCD) 중
onGlobalCooldown,
/// 스킬 없음
skillNotFound,

View File

@@ -0,0 +1,277 @@
import 'package:asciineverdie/data/skill_data.dart';
import 'package:asciineverdie/src/core/model/skill.dart';
/// 스킬 슬롯 제한 상수
///
/// 타입별로 장착 가능한 최대 스킬 수
class SkillSlotLimits {
SkillSlotLimits._();
static const int attack = 3;
static const int heal = 2;
static const int buff = 2;
static const int debuff = 1;
/// 타입별 슬롯 제한 반환
static int getLimit(SkillType type) {
return switch (type) {
SkillType.attack => attack,
SkillType.heal => heal,
SkillType.buff => buff,
SkillType.debuff => debuff,
};
}
}
/// 캐릭터가 장착한 스킬 슬롯
///
/// 타입별로 슬롯 제한이 있으며, 새 스킬이 기존 스킬보다
/// 강할 경우 자동으로 교체됨
class SkillSlots {
const SkillSlots({
this.attackSkills = const [],
this.healSkills = const [],
this.buffSkills = const [],
this.debuffSkills = const [],
});
/// 공격 스킬 (최대 3개)
final List<Skill> attackSkills;
/// 회복 스킬 (최대 2개)
final List<Skill> healSkills;
/// 버프 스킬 (최대 2개)
final List<Skill> buffSkills;
/// 디버프 스킬 (최대 1개)
final List<Skill> debuffSkills;
/// 장착된 모든 스킬 목록
List<Skill> get allSkills => [
...attackSkills,
...healSkills,
...buffSkills,
...debuffSkills,
];
/// 타입별 스킬 목록 반환
List<Skill> getSkillsByType(SkillType type) {
return switch (type) {
SkillType.attack => attackSkills,
SkillType.heal => healSkills,
SkillType.buff => buffSkills,
SkillType.debuff => debuffSkills,
};
}
/// 해당 타입 슬롯이 가득 찼는지 확인
bool isSlotFull(SkillType type) {
final current = getSkillsByType(type).length;
final limit = SkillSlotLimits.getLimit(type);
return current >= limit;
}
/// 스킬이 이미 장착되어 있는지 확인
bool hasSkill(String skillId) {
return allSkills.any((s) => s.id == skillId);
}
/// 새 스킬 추가 시도
///
/// 반환값: (성공 여부, 새 SkillSlots, 교체된 스킬)
/// - 슬롯에 여유가 있으면 추가
/// - 슬롯이 가득 차면 가장 약한 스킬과 비교 후 교체
/// - 새 스킬이 더 약하면 추가 실패
SkillAddResult tryAddSkill(Skill newSkill) {
// 이미 장착된 스킬인지 확인
if (hasSkill(newSkill.id)) {
return SkillAddResult(
success: false,
slots: this,
replacedSkill: null,
reason: SkillAddFailReason.alreadyEquipped,
);
}
final type = newSkill.type;
final currentSkills = List<Skill>.from(getSkillsByType(type));
final limit = SkillSlotLimits.getLimit(type);
// 슬롯에 여유가 있으면 그냥 추가
if (currentSkills.length < limit) {
currentSkills.add(newSkill);
return SkillAddResult(
success: true,
slots: _copyWith(type, currentSkills),
replacedSkill: null,
reason: null,
);
}
// 슬롯이 가득 찼으면 가장 약한 스킬 찾기
final weakest = _findWeakestSkill(currentSkills);
// 새 스킬이 가장 약한 스킬보다 강한지 확인
if (newSkill.isStrongerThan(weakest)) {
currentSkills.remove(weakest);
currentSkills.add(newSkill);
return SkillAddResult(
success: true,
slots: _copyWith(type, currentSkills),
replacedSkill: weakest,
reason: null,
);
}
// 새 스킬이 더 약함
return SkillAddResult(
success: false,
slots: this,
replacedSkill: null,
reason: SkillAddFailReason.weakerThanExisting,
);
}
/// 특정 스킬 제거
SkillSlots removeSkill(String skillId) {
for (final type in SkillType.values) {
final skills = getSkillsByType(type);
final index = skills.indexWhere((s) => s.id == skillId);
if (index != -1) {
final newSkills = List<Skill>.from(skills)..removeAt(index);
return _copyWith(type, newSkills);
}
}
return this;
}
/// 가장 약한 스킬 찾기 (powerScore 기준)
Skill _findWeakestSkill(List<Skill> skills) {
if (skills.isEmpty) {
throw ArgumentError('스킬 목록이 비어있습니다');
}
var weakest = skills.first;
for (final skill in skills.skip(1)) {
// tier가 낮거나, tier가 같으면 powerScore가 낮은 것이 약함
if (weakest.isStrongerThan(skill)) {
weakest = skill;
}
}
return weakest;
}
/// 타입별 스킬 목록을 교체한 새 SkillSlots 반환
SkillSlots _copyWith(SkillType type, List<Skill> skills) {
return switch (type) {
SkillType.attack => SkillSlots(
attackSkills: skills,
healSkills: healSkills,
buffSkills: buffSkills,
debuffSkills: debuffSkills,
),
SkillType.heal => SkillSlots(
attackSkills: attackSkills,
healSkills: skills,
buffSkills: buffSkills,
debuffSkills: debuffSkills,
),
SkillType.buff => SkillSlots(
attackSkills: attackSkills,
healSkills: healSkills,
buffSkills: skills,
debuffSkills: debuffSkills,
),
SkillType.debuff => SkillSlots(
attackSkills: attackSkills,
healSkills: healSkills,
buffSkills: buffSkills,
debuffSkills: skills,
),
};
}
/// copyWith 메서드
SkillSlots copyWith({
List<Skill>? attackSkills,
List<Skill>? healSkills,
List<Skill>? buffSkills,
List<Skill>? debuffSkills,
}) {
return SkillSlots(
attackSkills: attackSkills ?? this.attackSkills,
healSkills: healSkills ?? this.healSkills,
buffSkills: buffSkills ?? this.buffSkills,
debuffSkills: debuffSkills ?? this.debuffSkills,
);
}
/// JSON 직렬화 (저장용)
Map<String, dynamic> toJson() {
return {
'attackSkills': attackSkills.map((s) => s.id).toList(),
'healSkills': healSkills.map((s) => s.id).toList(),
'buffSkills': buffSkills.map((s) => s.id).toList(),
'debuffSkills': debuffSkills.map((s) => s.id).toList(),
};
}
/// JSON 역직렬화
factory SkillSlots.fromJson(Map<String, dynamic> json) {
List<Skill> parseSkillIds(List<dynamic>? ids) {
if (ids == null) return [];
return ids
.map((id) => SkillData.getSkillById(id as String))
.whereType<Skill>()
.toList();
}
return SkillSlots(
attackSkills: parseSkillIds(json['attackSkills'] as List<dynamic>?),
healSkills: parseSkillIds(json['healSkills'] as List<dynamic>?),
buffSkills: parseSkillIds(json['buffSkills'] as List<dynamic>?),
debuffSkills: parseSkillIds(json['debuffSkills'] as List<dynamic>?),
);
}
@override
String toString() {
return 'SkillSlots('
'attack: ${attackSkills.length}/${SkillSlotLimits.attack}, '
'heal: ${healSkills.length}/${SkillSlotLimits.heal}, '
'buff: ${buffSkills.length}/${SkillSlotLimits.buff}, '
'debuff: ${debuffSkills.length}/${SkillSlotLimits.debuff})';
}
}
/// 스킬 추가 결과
class SkillAddResult {
const SkillAddResult({
required this.success,
required this.slots,
required this.replacedSkill,
required this.reason,
});
/// 추가 성공 여부
final bool success;
/// 결과 SkillSlots (성공 시 새 슬롯, 실패 시 기존 슬롯)
final SkillSlots slots;
/// 교체된 스킬 (슬롯이 가득 차서 교체된 경우)
final Skill? replacedSkill;
/// 실패 사유 (실패 시에만 설정)
final SkillAddFailReason? reason;
}
/// 스킬 추가 실패 사유
enum SkillAddFailReason {
/// 이미 장착된 스킬
alreadyEquipped,
/// 기존 스킬보다 약함
weakerThanExisting,
}