Compare commits

..

6 Commits

Author SHA1 Message Date
JiWoong Sul
249394f548 test: GCD 시뮬레이션 테스트 추가
- 다양한 GCD 값(0~3000ms)에 대한 전투 효율성 비교
- 레벨별 GCD 영향 분석 테스트
- DPS, 스킬 사용 빈도, 전투 시간 측정
- 권장 GCD 분석 결과: 1500~2000ms
2026-01-14 23:04:52 +09:00
JiWoong Sul
85413362a2 fix(ui): 몬스터 사망 애니메이션 상태 리셋 버그 수정
- 새 몬스터 등장 시 이전 사망 애니메이션 상태 초기화
- 전투 시작 시 _showDeathAnimation, _deathAnimationMonsterLines 리셋
- 이전 몬스터 사망 애니메이션 도중 새 전투 시작 시 몬스터 숨김 방지
2026-01-14 23:04:45 +09:00
JiWoong Sul
02d4d1d397 feat(engine): GCD 체크 및 스킬 자동 장착 로직 구현
SkillService:
- canUseSkill()에 GCD 체크 추가
- selectAutoSkill() 확률 조정 (70% 일반공격, 30% 스킬)
- 버프/디버프 조건 강화 (HP>80%, 활성 효과 체크)

ProgressService:
- 스킬 사용 후 GCD 시작 로직 추가
- 장착된 스킬 슬롯에서 사용 가능 스킬 조회
- 비전투 태스크 시 currentCombat 초기화

GameMutations:
- winSpell()에서 스펠 획득 시 전투 스킬 자동 장착
2026-01-14 23:04:38 +09:00
JiWoong Sul
c0d32b1c87 feat(core): SkillSystemState에 GCD 및 스킬 슬롯 추가
- globalCooldownEndMs: GCD 종료 시점 추적
- globalCooldownDuration: 1500ms 상수 정의
- equippedSkills: 장착된 스킬 슬롯 (SkillSlots)
- isGlobalCooldownActive, remainingGlobalCooldown getter
- startGlobalCooldown() 메서드 추가
2026-01-14 23:04:13 +09:00
JiWoong Sul
8112173541 feat(data): 모든 스킬에 티어 정보 추가
- 공격/회복/버프/디버프 전 스킬에 tier(1~5) 설정
- 티어 기준: 스킬 강도, MP 소모, 쿨타임 등 종합 고려
- 티어 5: Kernel Panic, Chaos Monkey, Snapshot Restore 등
- 티어 1: Stack Trace, Hot Reload, Step Into 등
2026-01-14 23:04:07 +09:00
JiWoong Sul
2621942ced feat(model): 스킬 티어 및 파워 스코어 시스템 추가
- Skill 클래스에 tier 필드 추가 (1~5, 높을수록 강함)
- 타입별 powerScore 동적 계산 로직 구현
- isStrongerThan() 메서드로 스킬 강도 비교 지원
- SkillFailReason에 onGlobalCooldown 추가
- SkillSlots 클래스 신규 추가 (타입별 슬롯 제한)
2026-01-14 23:03:51 +09:00
9 changed files with 820 additions and 33 deletions

View File

@@ -16,6 +16,7 @@ class SkillData {
id: 'stack_trace', id: 'stack_trace',
name: 'Stack Trace', name: 'Stack Trace',
type: SkillType.attack, type: SkillType.attack,
tier: 1,
mpCost: 10, mpCost: 10,
cooldownMs: 3000, cooldownMs: 3000,
power: 15, power: 15,
@@ -27,6 +28,7 @@ class SkillData {
id: 'core_dump', id: 'core_dump',
name: 'Core Dump', name: 'Core Dump',
type: SkillType.attack, type: SkillType.attack,
tier: 3,
mpCost: 35, mpCost: 35,
cooldownMs: 12000, cooldownMs: 12000,
power: 30, power: 30,
@@ -38,6 +40,7 @@ class SkillData {
id: 'memory_dump', id: 'memory_dump',
name: 'Memory Dump', name: 'Memory Dump',
type: SkillType.attack, type: SkillType.attack,
tier: 3,
mpCost: 25, mpCost: 25,
cooldownMs: 15000, cooldownMs: 15000,
power: 0, power: 0,
@@ -53,6 +56,7 @@ class SkillData {
id: 'kernel_panic', id: 'kernel_panic',
name: 'Kernel Panic', name: 'Kernel Panic',
type: SkillType.attack, type: SkillType.attack,
tier: 5,
mpCost: 80, mpCost: 80,
cooldownMs: 45000, cooldownMs: 45000,
power: 60, power: 60,
@@ -65,6 +69,7 @@ class SkillData {
id: 'blue_screen', id: 'blue_screen',
name: 'Blue Screen', name: 'Blue Screen',
type: SkillType.attack, type: SkillType.attack,
tier: 4,
mpCost: 60, mpCost: 60,
cooldownMs: 30000, cooldownMs: 30000,
power: 50, power: 50,
@@ -76,6 +81,7 @@ class SkillData {
id: 'inject_code', id: 'inject_code',
name: 'Inject Code', name: 'Inject Code',
type: SkillType.attack, type: SkillType.attack,
tier: 4,
mpCost: 40, mpCost: 40,
cooldownMs: 18000, cooldownMs: 18000,
power: 35, power: 35,
@@ -88,6 +94,7 @@ class SkillData {
id: 'spawn_shell', id: 'spawn_shell',
name: 'Spawn Shell', name: 'Spawn Shell',
type: SkillType.attack, type: SkillType.attack,
tier: 3,
mpCost: 30, mpCost: 30,
cooldownMs: 10000, cooldownMs: 10000,
power: 12, power: 12,
@@ -100,6 +107,7 @@ class SkillData {
id: 'thread_pool', id: 'thread_pool',
name: 'Thread Pool', name: 'Thread Pool',
type: SkillType.attack, type: SkillType.attack,
tier: 4,
mpCost: 45, mpCost: 45,
cooldownMs: 15000, cooldownMs: 15000,
power: 10, power: 10,
@@ -112,6 +120,7 @@ class SkillData {
id: 'exfiltrate_data', id: 'exfiltrate_data',
name: 'Exfiltrate Data', name: 'Exfiltrate Data',
type: SkillType.attack, type: SkillType.attack,
tier: 4,
mpCost: 35, mpCost: 35,
cooldownMs: 12000, cooldownMs: 12000,
power: 25, power: 25,
@@ -124,6 +133,7 @@ class SkillData {
id: 'fuzzing', id: 'fuzzing',
name: 'Fuzzing', name: 'Fuzzing',
type: SkillType.attack, type: SkillType.attack,
tier: 2,
mpCost: 20, mpCost: 20,
cooldownMs: 8000, cooldownMs: 8000,
power: 20, power: 20,
@@ -136,6 +146,7 @@ class SkillData {
id: 'chaos_monkey', id: 'chaos_monkey',
name: 'Chaos Monkey', name: 'Chaos Monkey',
type: SkillType.attack, type: SkillType.attack,
tier: 5,
mpCost: 50, mpCost: 50,
cooldownMs: 25000, cooldownMs: 25000,
power: 40, power: 40,
@@ -148,6 +159,7 @@ class SkillData {
id: 'saga_pattern', id: 'saga_pattern',
name: 'Saga Pattern', name: 'Saga Pattern',
type: SkillType.attack, type: SkillType.attack,
tier: 4,
mpCost: 55, mpCost: 55,
cooldownMs: 20000, cooldownMs: 20000,
power: 18, power: 18,
@@ -160,6 +172,7 @@ class SkillData {
id: 'event_store', id: 'event_store',
name: 'Event Store', name: 'Event Store',
type: SkillType.attack, type: SkillType.attack,
tier: 4,
mpCost: 40, mpCost: 40,
cooldownMs: 18000, cooldownMs: 18000,
power: 0, power: 0,
@@ -175,6 +188,7 @@ class SkillData {
id: 'auto_scale', id: 'auto_scale',
name: 'Auto Scale', name: 'Auto Scale',
type: SkillType.attack, type: SkillType.attack,
tier: 4,
mpCost: 45, mpCost: 45,
cooldownMs: 20000, cooldownMs: 20000,
power: 30, power: 30,
@@ -186,6 +200,7 @@ class SkillData {
id: 'disassemble', id: 'disassemble',
name: 'Disassemble', name: 'Disassemble',
type: SkillType.attack, type: SkillType.attack,
tier: 3,
mpCost: 30, mpCost: 30,
cooldownMs: 12000, cooldownMs: 12000,
power: 22, power: 22,
@@ -198,6 +213,7 @@ class SkillData {
id: 'decompile', id: 'decompile',
name: 'Decompile', name: 'Decompile',
type: SkillType.attack, type: SkillType.attack,
tier: 2,
mpCost: 25, mpCost: 25,
cooldownMs: 10000, cooldownMs: 10000,
power: 20, power: 20,
@@ -209,6 +225,7 @@ class SkillData {
id: 'canary_release', id: 'canary_release',
name: 'Canary Release', name: 'Canary Release',
type: SkillType.attack, type: SkillType.attack,
tier: 1,
mpCost: 15, mpCost: 15,
cooldownMs: 6000, cooldownMs: 6000,
power: 12, power: 12,
@@ -220,6 +237,7 @@ class SkillData {
id: 'ab_test', id: 'ab_test',
name: 'A/B Test', name: 'A/B Test',
type: SkillType.attack, type: SkillType.attack,
tier: 2,
mpCost: 35, mpCost: 35,
cooldownMs: 12000, cooldownMs: 12000,
power: 15, power: 15,
@@ -232,6 +250,7 @@ class SkillData {
id: 'pivot_network', id: 'pivot_network',
name: 'Pivot Network', name: 'Pivot Network',
type: SkillType.attack, type: SkillType.attack,
tier: 3,
mpCost: 30, mpCost: 30,
cooldownMs: 10000, cooldownMs: 10000,
power: 25, power: 25,
@@ -244,6 +263,7 @@ class SkillData {
id: 'async_await', id: 'async_await',
name: 'Async Await', name: 'Async Await',
type: SkillType.attack, type: SkillType.attack,
tier: 4,
mpCost: 35, mpCost: 35,
cooldownMs: 14000, cooldownMs: 14000,
power: 35, power: 35,
@@ -255,6 +275,7 @@ class SkillData {
id: 'event_source', id: 'event_source',
name: 'Event Source', name: 'Event Source',
type: SkillType.attack, type: SkillType.attack,
tier: 3,
mpCost: 30, mpCost: 30,
cooldownMs: 12000, cooldownMs: 12000,
power: 0, power: 0,
@@ -270,6 +291,7 @@ class SkillData {
id: 'cqrs_split', id: 'cqrs_split',
name: 'CQRS Split', name: 'CQRS Split',
type: SkillType.attack, type: SkillType.attack,
tier: 4,
mpCost: 40, mpCost: 40,
cooldownMs: 15000, cooldownMs: 15000,
power: 20, power: 20,
@@ -286,6 +308,7 @@ class SkillData {
id: 'garbage_collection', id: 'garbage_collection',
name: 'Garbage Collection', name: 'Garbage Collection',
type: SkillType.heal, type: SkillType.heal,
tier: 3,
mpCost: 25, mpCost: 25,
cooldownMs: 15000, cooldownMs: 15000,
power: 0, power: 0,
@@ -297,6 +320,7 @@ class SkillData {
id: 'hot_reload', id: 'hot_reload',
name: 'Hot Reload', name: 'Hot Reload',
type: SkillType.heal, type: SkillType.heal,
tier: 1,
mpCost: 15, mpCost: 15,
cooldownMs: 8000, cooldownMs: 8000,
power: 0, power: 0,
@@ -308,6 +332,7 @@ class SkillData {
id: 'rollback', id: 'rollback',
name: 'Rollback', name: 'Rollback',
type: SkillType.heal, type: SkillType.heal,
tier: 4,
mpCost: 30, mpCost: 30,
cooldownMs: 18000, cooldownMs: 18000,
power: 0, power: 0,
@@ -319,6 +344,7 @@ class SkillData {
id: 'hotfix', id: 'hotfix',
name: 'Hotfix', name: 'Hotfix',
type: SkillType.heal, type: SkillType.heal,
tier: 1,
mpCost: 10, mpCost: 10,
cooldownMs: 6000, cooldownMs: 6000,
power: 0, power: 0,
@@ -330,6 +356,7 @@ class SkillData {
id: 'snapshot_restore', id: 'snapshot_restore',
name: 'Snapshot Restore', name: 'Snapshot Restore',
type: SkillType.heal, type: SkillType.heal,
tier: 5,
mpCost: 50, mpCost: 50,
cooldownMs: 30000, cooldownMs: 30000,
power: 0, power: 0,
@@ -341,6 +368,7 @@ class SkillData {
id: 'patch_binary', id: 'patch_binary',
name: 'Patch Binary', name: 'Patch Binary',
type: SkillType.heal, type: SkillType.heal,
tier: 4,
mpCost: 35, mpCost: 35,
cooldownMs: 20000, cooldownMs: 20000,
power: 0, power: 0,
@@ -358,6 +386,7 @@ class SkillData {
id: 'git_commit', id: 'git_commit',
name: 'Git Commit', name: 'Git Commit',
type: SkillType.heal, type: SkillType.heal,
tier: 2,
mpCost: 20, mpCost: 20,
cooldownMs: 12000, cooldownMs: 12000,
power: 0, power: 0,
@@ -369,6 +398,7 @@ class SkillData {
id: 'git_push', id: 'git_push',
name: 'Git Push', name: 'Git Push',
type: SkillType.heal, type: SkillType.heal,
tier: 3,
mpCost: 25, mpCost: 25,
cooldownMs: 15000, cooldownMs: 15000,
power: 0, power: 0,
@@ -380,6 +410,7 @@ class SkillData {
id: 'connection_pool', id: 'connection_pool',
name: 'Connection Pool', name: 'Connection Pool',
type: SkillType.heal, type: SkillType.heal,
tier: 3,
mpCost: 0, mpCost: 0,
cooldownMs: 20000, cooldownMs: 20000,
power: 0, power: 0,
@@ -391,6 +422,7 @@ class SkillData {
id: 'load_balance', id: 'load_balance',
name: 'Load Balance', name: 'Load Balance',
type: SkillType.heal, type: SkillType.heal,
tier: 4,
mpCost: 20, mpCost: 20,
cooldownMs: 15000, cooldownMs: 15000,
power: 0, power: 0,
@@ -403,6 +435,7 @@ class SkillData {
id: 'blue_green_deploy', id: 'blue_green_deploy',
name: 'Blue Green Deploy', name: 'Blue Green Deploy',
type: SkillType.heal, type: SkillType.heal,
tier: 4,
mpCost: 30, mpCost: 30,
cooldownMs: 25000, cooldownMs: 25000,
power: 0, power: 0,
@@ -414,6 +447,7 @@ class SkillData {
id: 'cache_invalidate', id: 'cache_invalidate',
name: 'Cache Invalidate', name: 'Cache Invalidate',
type: SkillType.heal, type: SkillType.heal,
tier: 2,
mpCost: 25, mpCost: 25,
cooldownMs: 18000, cooldownMs: 18000,
power: 0, power: 0,
@@ -429,6 +463,7 @@ class SkillData {
id: 'debug_mode', id: 'debug_mode',
name: 'Debug Mode', name: 'Debug Mode',
type: SkillType.buff, type: SkillType.buff,
tier: 3,
mpCost: 20, mpCost: 20,
cooldownMs: 20000, cooldownMs: 20000,
power: 0, power: 0,
@@ -445,6 +480,7 @@ class SkillData {
id: 'safe_mode', id: 'safe_mode',
name: 'Safe Mode', name: 'Safe Mode',
type: SkillType.buff, type: SkillType.buff,
tier: 3,
mpCost: 25, mpCost: 25,
cooldownMs: 25000, cooldownMs: 25000,
power: 0, power: 0,
@@ -461,6 +497,7 @@ class SkillData {
id: 'memory_optimization', id: 'memory_optimization',
name: 'Memory Optimization', name: 'Memory Optimization',
type: SkillType.buff, type: SkillType.buff,
tier: 3,
mpCost: 30, mpCost: 30,
cooldownMs: 30000, cooldownMs: 30000,
power: 0, power: 0,
@@ -480,6 +517,7 @@ class SkillData {
id: 'breakpoint', id: 'breakpoint',
name: 'Breakpoint', name: 'Breakpoint',
type: SkillType.buff, type: SkillType.buff,
tier: 2,
mpCost: 15, mpCost: 15,
cooldownMs: 12000, cooldownMs: 12000,
power: 0, power: 0,
@@ -496,6 +534,7 @@ class SkillData {
id: 'watch_variable', id: 'watch_variable',
name: 'Watch Variable', name: 'Watch Variable',
type: SkillType.buff, type: SkillType.buff,
tier: 2,
mpCost: 18, mpCost: 18,
cooldownMs: 15000, cooldownMs: 15000,
power: 0, power: 0,
@@ -512,6 +551,7 @@ class SkillData {
id: 'step_into', id: 'step_into',
name: 'Step Into', name: 'Step Into',
type: SkillType.buff, type: SkillType.buff,
tier: 1,
mpCost: 15, mpCost: 15,
cooldownMs: 12000, cooldownMs: 12000,
power: 0, power: 0,
@@ -528,6 +568,7 @@ class SkillData {
id: 'profile_run', id: 'profile_run',
name: 'Profile Run', name: 'Profile Run',
type: SkillType.buff, type: SkillType.buff,
tier: 3,
mpCost: 20, mpCost: 20,
cooldownMs: 18000, cooldownMs: 18000,
power: 0, power: 0,
@@ -544,6 +585,7 @@ class SkillData {
id: 'benchmark', id: 'benchmark',
name: 'Benchmark', name: 'Benchmark',
type: SkillType.buff, type: SkillType.buff,
tier: 4,
mpCost: 25, mpCost: 25,
cooldownMs: 20000, cooldownMs: 20000,
power: 0, power: 0,
@@ -560,6 +602,7 @@ class SkillData {
id: 'elevate_privilege', id: 'elevate_privilege',
name: 'Elevate Privilege', name: 'Elevate Privilege',
type: SkillType.buff, type: SkillType.buff,
tier: 5,
mpCost: 40, mpCost: 40,
cooldownMs: 35000, cooldownMs: 35000,
power: 0, power: 0,
@@ -579,6 +622,7 @@ class SkillData {
id: 'scale_up', id: 'scale_up',
name: 'Scale Up', name: 'Scale Up',
type: SkillType.buff, type: SkillType.buff,
tier: 5,
mpCost: 35, mpCost: 35,
cooldownMs: 30000, cooldownMs: 30000,
power: 0, power: 0,
@@ -595,6 +639,7 @@ class SkillData {
id: 'failover', id: 'failover',
name: 'Failover', name: 'Failover',
type: SkillType.buff, type: SkillType.buff,
tier: 5,
mpCost: 30, mpCost: 30,
cooldownMs: 45000, cooldownMs: 45000,
power: 0, power: 0,
@@ -611,6 +656,7 @@ class SkillData {
id: 'containerize', id: 'containerize',
name: 'Containerize', name: 'Containerize',
type: SkillType.buff, type: SkillType.buff,
tier: 3,
mpCost: 25, mpCost: 25,
cooldownMs: 20000, cooldownMs: 20000,
power: 0, power: 0,
@@ -627,6 +673,7 @@ class SkillData {
id: 'orchestrate', id: 'orchestrate',
name: 'Orchestrate', name: 'Orchestrate',
type: SkillType.buff, type: SkillType.buff,
tier: 4,
mpCost: 45, mpCost: 45,
cooldownMs: 40000, cooldownMs: 40000,
power: 0, power: 0,
@@ -645,6 +692,7 @@ class SkillData {
id: 'promise_resolve', id: 'promise_resolve',
name: 'Promise Resolve', name: 'Promise Resolve',
type: SkillType.buff, type: SkillType.buff,
tier: 2,
mpCost: 20, mpCost: 20,
cooldownMs: 15000, cooldownMs: 15000,
power: 0, power: 0,
@@ -661,6 +709,7 @@ class SkillData {
id: 'feature_toggle', id: 'feature_toggle',
name: 'Feature Toggle', name: 'Feature Toggle',
type: SkillType.buff, type: SkillType.buff,
tier: 1,
mpCost: 15, mpCost: 15,
cooldownMs: 10000, cooldownMs: 10000,
power: 0, power: 0,
@@ -678,6 +727,7 @@ class SkillData {
id: 'dark_launch', id: 'dark_launch',
name: 'Dark Launch', name: 'Dark Launch',
type: SkillType.buff, type: SkillType.buff,
tier: 4,
mpCost: 35, mpCost: 35,
cooldownMs: 30000, cooldownMs: 30000,
power: 0, power: 0,
@@ -694,6 +744,7 @@ class SkillData {
id: 'static_analysis', id: 'static_analysis',
name: 'Static Analysis', name: 'Static Analysis',
type: SkillType.buff, type: SkillType.buff,
tier: 1,
mpCost: 15, mpCost: 15,
cooldownMs: 12000, cooldownMs: 12000,
power: 0, power: 0,
@@ -710,6 +761,7 @@ class SkillData {
id: 'dynamic_analysis', id: 'dynamic_analysis',
name: 'Dynamic Analysis', name: 'Dynamic Analysis',
type: SkillType.buff, type: SkillType.buff,
tier: 4,
mpCost: 25, mpCost: 25,
cooldownMs: 18000, cooldownMs: 18000,
power: 0, power: 0,
@@ -727,6 +779,7 @@ class SkillData {
id: 'reverse_engineer', id: 'reverse_engineer',
name: 'Reverse Engineer', name: 'Reverse Engineer',
type: SkillType.buff, type: SkillType.buff,
tier: 4,
mpCost: 30, mpCost: 30,
cooldownMs: 25000, cooldownMs: 25000,
power: 0, power: 0,
@@ -743,6 +796,7 @@ class SkillData {
id: 'cover_tracks', id: 'cover_tracks',
name: 'Cover Tracks', name: 'Cover Tracks',
type: SkillType.buff, type: SkillType.buff,
tier: 2,
mpCost: 20, mpCost: 20,
cooldownMs: 15000, cooldownMs: 15000,
power: 0, power: 0,
@@ -759,6 +813,7 @@ class SkillData {
id: 'deploy', id: 'deploy',
name: 'Deploy', name: 'Deploy',
type: SkillType.buff, type: SkillType.buff,
tier: 4,
mpCost: 35, mpCost: 35,
cooldownMs: 30000, cooldownMs: 30000,
power: 0, power: 0,
@@ -775,6 +830,7 @@ class SkillData {
id: 'retry_logic', id: 'retry_logic',
name: 'Retry Logic', name: 'Retry Logic',
type: SkillType.buff, type: SkillType.buff,
tier: 2,
mpCost: 20, mpCost: 20,
cooldownMs: 15000, cooldownMs: 15000,
power: 0, power: 0,
@@ -791,6 +847,7 @@ class SkillData {
id: 'state_machine', id: 'state_machine',
name: 'State Machine', name: 'State Machine',
type: SkillType.buff, type: SkillType.buff,
tier: 2,
mpCost: 30, mpCost: 30,
cooldownMs: 25000, cooldownMs: 25000,
power: 0, power: 0,
@@ -812,6 +869,7 @@ class SkillData {
id: 'step_over', id: 'step_over',
name: 'Step Over', name: 'Step Over',
type: SkillType.debuff, type: SkillType.debuff,
tier: 1,
mpCost: 20, mpCost: 20,
cooldownMs: 15000, cooldownMs: 15000,
power: 0, power: 0,
@@ -828,6 +886,7 @@ class SkillData {
id: 'cold_boot', id: 'cold_boot',
name: 'Cold Boot', name: 'Cold Boot',
type: SkillType.debuff, type: SkillType.debuff,
tier: 4,
mpCost: 30, mpCost: 30,
cooldownMs: 25000, cooldownMs: 25000,
power: 0, power: 0,
@@ -845,6 +904,7 @@ class SkillData {
id: 'heap_analysis', id: 'heap_analysis',
name: 'Heap Analysis', name: 'Heap Analysis',
type: SkillType.debuff, type: SkillType.debuff,
tier: 2,
mpCost: 25, mpCost: 25,
cooldownMs: 18000, cooldownMs: 18000,
power: 0, power: 0,
@@ -861,6 +921,7 @@ class SkillData {
id: 'unit_test', id: 'unit_test',
name: 'Unit Test', name: 'Unit Test',
type: SkillType.debuff, type: SkillType.debuff,
tier: 1,
mpCost: 20, mpCost: 20,
cooldownMs: 15000, cooldownMs: 15000,
power: 0, power: 0,
@@ -877,6 +938,7 @@ class SkillData {
id: 'integration_test', id: 'integration_test',
name: 'Integration Test', name: 'Integration Test',
type: SkillType.debuff, type: SkillType.debuff,
tier: 2,
mpCost: 25, mpCost: 25,
cooldownMs: 18000, cooldownMs: 18000,
power: 0, power: 0,
@@ -894,6 +956,7 @@ class SkillData {
id: 'sanitizer', id: 'sanitizer',
name: 'Sanitizer', name: 'Sanitizer',
type: SkillType.debuff, type: SkillType.debuff,
tier: 3,
mpCost: 30, mpCost: 30,
cooldownMs: 20000, cooldownMs: 20000,
power: 0, power: 0,
@@ -911,6 +974,7 @@ class SkillData {
id: 'hook_function', id: 'hook_function',
name: 'Hook Function', name: 'Hook Function',
type: SkillType.debuff, type: SkillType.debuff,
tier: 2,
mpCost: 25, mpCost: 25,
cooldownMs: 22000, cooldownMs: 22000,
power: 0, power: 0,
@@ -927,6 +991,7 @@ class SkillData {
id: 'rate_limit', id: 'rate_limit',
name: 'Rate Limit', name: 'Rate Limit',
type: SkillType.debuff, type: SkillType.debuff,
tier: 3,
mpCost: 30, mpCost: 30,
cooldownMs: 25000, cooldownMs: 25000,
power: 0, power: 0,
@@ -943,6 +1008,7 @@ class SkillData {
id: 'circuit_break', id: 'circuit_break',
name: 'Circuit Break', name: 'Circuit Break',
type: SkillType.debuff, type: SkillType.debuff,
tier: 4,
mpCost: 35, mpCost: 35,
cooldownMs: 30000, cooldownMs: 30000,
power: 0, power: 0,
@@ -959,6 +1025,7 @@ class SkillData {
id: 'backpressure', id: 'backpressure',
name: 'Backpressure', name: 'Backpressure',
type: SkillType.debuff, type: SkillType.debuff,
tier: 2,
mpCost: 25, mpCost: 25,
cooldownMs: 20000, cooldownMs: 20000,
power: 0, power: 0,
@@ -975,6 +1042,7 @@ class SkillData {
id: 'git_merge', id: 'git_merge',
name: 'Git Merge', name: 'Git Merge',
type: SkillType.debuff, type: SkillType.debuff,
tier: 2,
mpCost: 20, mpCost: 20,
cooldownMs: 15000, cooldownMs: 15000,
power: 0, power: 0,

View File

@@ -1,3 +1,4 @@
import 'package:asciineverdie/data/skill_data.dart';
import 'package:asciineverdie/src/core/engine/item_service.dart'; import 'package:asciineverdie/src/core/engine/item_service.dart';
import 'package:asciineverdie/src/core/model/equipment_slot.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/game_state.dart';
@@ -43,12 +44,17 @@ class GameMutations {
return state.copyWith(rng: state.rng, stats: updatedStats); return state.copyWith(rng: state.rng, stats: updatedStats);
} }
/// 스펠 획득 (원본 WinSpell)
///
/// 스펠북에 추가하고, 전투용 스킬 슬롯에도 자동으로 장착 시도.
/// 슬롯이 가득 찬 경우 기존 스킬보다 강할 때만 교체됨.
GameState winSpell(GameState state, int wisdom, int level) { GameState winSpell(GameState state, int wisdom, int level) {
final result = pq_logic.winSpell(config, state.rng, wisdom, level); final result = pq_logic.winSpell(config, state.rng, wisdom, level);
final parts = result.split('|'); final parts = result.split('|');
final name = parts[0]; final name = parts[0];
final rank = parts.length > 1 ? parts[1] : 'I'; final rank = parts.length > 1 ? parts[1] : 'I';
// 스펠북 업데이트
final skills = [...state.skillBook.skills]; final skills = [...state.skillBook.skills];
final index = skills.indexWhere((s) => s.name == name); final index = skills.indexWhere((s) => s.name == name);
if (index >= 0) { if (index >= 0) {
@@ -57,9 +63,20 @@ class GameMutations {
skills.add(SkillEntry(name: name, rank: rank)); skills.add(SkillEntry(name: name, rank: rank));
} }
// 전투 스킬 슬롯에 추가 시도
var skillSystem = state.skillSystem;
final combatSkill = SkillData.getSkillBySpellName(name);
if (combatSkill != null) {
final addResult = skillSystem.equippedSkills.tryAddSkill(combatSkill);
if (addResult.success) {
skillSystem = skillSystem.copyWith(equippedSkills: addResult.slots);
}
}
return state.copyWith( return state.copyWith(
rng: state.rng, rng: state.rng,
skillBook: state.skillBook.copyWith(skills: skills), skillBook: state.skillBook.copyWith(skills: skills),
skillSystem: skillSystem,
); );
} }

View File

@@ -512,6 +512,7 @@ class ProgressService {
caption: taskResult.caption, caption: taskResult.caption,
type: TaskType.market, type: TaskType.market,
), ),
currentCombat: null, // 비전투 태스크이므로 전투 상태 초기화
); );
return (progress: progress, queue: queue); return (progress: progress, queue: queue);
} }
@@ -536,6 +537,7 @@ class ProgressService {
caption: taskResult.caption, caption: taskResult.caption,
type: TaskType.buying, type: TaskType.buying,
), ),
currentCombat: null, // 비전투 태스크이므로 전투 상태 초기화
); );
return (progress: progress, queue: queue); return (progress: progress, queue: queue);
} }
@@ -551,6 +553,7 @@ class ProgressService {
caption: taskResult.caption, caption: taskResult.caption,
type: TaskType.neutral, type: TaskType.neutral,
), ),
currentCombat: null, // 비전투 태스크이므로 전투 상태 초기화
); );
return (progress: progress, queue: queue); return (progress: progress, queue: queue);
} }
@@ -672,7 +675,7 @@ class ProgressService {
type: TaskType.kill, type: TaskType.kill,
monsterBaseName: monsterResult.baseName, monsterBaseName: monsterResult.baseName,
monsterPart: monsterResult.part, monsterPart: monsterResult.part,
monsterLevel: monsterResult.level, monsterLevel: effectiveMonsterLevel,
monsterGrade: monsterResult.grade, monsterGrade: monsterResult.grade,
), ),
currentCombat: combatState, currentCombat: combatState,
@@ -1205,6 +1208,7 @@ class ProgressService {
); );
final progress = taskResult.progress.copyWith( final progress = taskResult.progress.copyWith(
currentTask: TaskInfo(caption: taskResult.caption, type: TaskType.sell), currentTask: TaskInfo(caption: taskResult.caption, type: TaskType.sell),
currentCombat: null, // 비전투 태스크이므로 전투 상태 초기화
); );
return ( return (
state: state.copyWith( state: state.copyWith(
@@ -1358,11 +1362,11 @@ class ProgressService {
// 플레이어 공격 체크 // 플레이어 공격 체크
if (playerAccumulator >= playerStats.attackDelayMs) { if (playerAccumulator >= playerStats.attackDelayMs) {
// SkillBook에서 사용 가능한 스킬 ID 목록 조회 // 장착된 스킬 슬롯에서 사용 가능한 스킬 ID 목록 조회
var availableSkillIds = skillService.getAvailableSkillIdsFromSkillBook( var availableSkillIds = state.skillSystem.equippedSkills.allSkills
state.skillBook, .map((s) => s.id)
); .toList();
// SkillBook에 스킬이 없으면 기본 스킬 사용 // 장착된 스킬이 없으면 기본 스킬 사용
if (availableSkillIds.isEmpty) { if (availableSkillIds.isEmpty) {
availableSkillIds = SkillData.defaultSkillIds; availableSkillIds = SkillData.defaultSkillIds;
} }
@@ -1395,6 +1399,9 @@ class ProgressService {
totalDamageDealt += skillResult.result.damage; totalDamageDealt += skillResult.result.damage;
updatedSkillSystem = skillResult.updatedSkillSystem; updatedSkillSystem = skillResult.updatedSkillSystem;
// GCD 시작 (스킬 사용 후)
updatedSkillSystem = updatedSkillSystem.startGlobalCooldown();
// 스킬 공격 이벤트 생성 // 스킬 공격 이벤트 생성
newEvents.add( newEvents.add(
CombatEvent.playerSkill( CombatEvent.playerSkill(
@@ -1417,6 +1424,9 @@ class ProgressService {
playerStats = skillResult.updatedPlayer; playerStats = skillResult.updatedPlayer;
updatedSkillSystem = skillResult.updatedSkillSystem; updatedSkillSystem = skillResult.updatedSkillSystem;
// GCD 시작 (스킬 사용 후)
updatedSkillSystem = updatedSkillSystem.startGlobalCooldown();
// DOT 효과 추가 // DOT 효과 추가
if (skillResult.dotEffect != null) { if (skillResult.dotEffect != null) {
activeDoTs.add(skillResult.dotEffect!); activeDoTs.add(skillResult.dotEffect!);
@@ -1442,6 +1452,9 @@ class ProgressService {
playerStats = skillResult.updatedPlayer; playerStats = skillResult.updatedPlayer;
updatedSkillSystem = skillResult.updatedSkillSystem; updatedSkillSystem = skillResult.updatedSkillSystem;
// GCD 시작 (스킬 사용 후)
updatedSkillSystem = updatedSkillSystem.startGlobalCooldown();
// 회복 이벤트 생성 // 회복 이벤트 생성
newEvents.add( newEvents.add(
CombatEvent.playerHeal( CombatEvent.playerHeal(
@@ -1460,6 +1473,9 @@ class ProgressService {
playerStats = skillResult.updatedPlayer; playerStats = skillResult.updatedPlayer;
updatedSkillSystem = skillResult.updatedSkillSystem; updatedSkillSystem = skillResult.updatedSkillSystem;
// GCD 시작 (스킬 사용 후)
updatedSkillSystem = updatedSkillSystem.startGlobalCooldown();
// 버프 이벤트 생성 // 버프 이벤트 생성
newEvents.add( newEvents.add(
CombatEvent.playerBuff( CombatEvent.playerBuff(
@@ -1478,6 +1494,9 @@ class ProgressService {
playerStats = skillResult.updatedPlayer; playerStats = skillResult.updatedPlayer;
updatedSkillSystem = skillResult.updatedSkillSystem; updatedSkillSystem = skillResult.updatedSkillSystem;
// GCD 시작 (스킬 사용 후)
updatedSkillSystem = updatedSkillSystem.startGlobalCooldown();
// 디버프 효과 추가 (기존 같은 디버프 제거 후) // 디버프 효과 추가 (기존 같은 디버프 제거 후)
if (skillResult.debuffEffect != null) { if (skillResult.debuffEffect != null) {
activeDebuffs = activeDebuffs =
@@ -1708,8 +1727,10 @@ class ProgressService {
if (equippedNonWeaponSlots.isNotEmpty) { if (equippedNonWeaponSlots.isNotEmpty) {
lostCount = 1; lostCount = 1;
// 랜덤하게 1개 슬롯 선택 // 랜덤하게 1개 슬롯 선택
final sacrificeIndex = equippedNonWeaponSlots[ final sacrificeIndex =
state.rng.nextInt(equippedNonWeaponSlots.length)]; equippedNonWeaponSlots[state.rng.nextInt(
equippedNonWeaponSlots.length,
)];
final slot = EquipmentSlot.values[sacrificeIndex]; final slot = EquipmentSlot.values[sacrificeIndex];
// 해당 슬롯을 빈 장비로 교체 // 해당 슬롯을 빈 장비로 교체
@@ -1733,7 +1754,8 @@ class ProgressService {
// 보스전 사망 시 5분 레벨링 모드 진입 // 보스전 사망 시 5분 레벨링 모드 진입
final bossLevelingEndTime = isBossDeath final bossLevelingEndTime = isBossDeath
? DateTime.now().millisecondsSinceEpoch + (5 * 60 * 1000) // 5분 ? DateTime.now().millisecondsSinceEpoch +
(5 * 60 * 1000) // 5분
: null; : null;
// 전투 상태 초기화 및 사망 횟수 증가 // 전투 상태 초기화 및 사망 횟수 증가

View File

@@ -24,6 +24,11 @@ class SkillService {
required int currentMp, required int currentMp,
required SkillSystemState skillSystem, required SkillSystemState skillSystem,
}) { }) {
// GCD 체크 (글로벌 쿨타임 1500ms)
if (skillSystem.isGlobalCooldownActive) {
return SkillFailReason.onGlobalCooldown;
}
// MP 체크 // MP 체크
if (currentMp < skill.mpCost) { if (currentMp < skill.mpCost) {
return SkillFailReason.notEnoughMp; return SkillFailReason.notEnoughMp;
@@ -297,13 +302,14 @@ class SkillService {
/// 전투 중 자동 스킬 선택 /// 전투 중 자동 스킬 선택
/// ///
/// 우선순위: /// 우선순위:
/// 1. HP < 30% → 회복 스킬 /// 1. HP < 30% → 회복 스킬 (최우선)
/// 2. HP > 70% & MP > 50% → 버프 스킬 (안전할 때) /// 2. 70% 확률로 일반 공격 (MP 절약, 기본 전투)
/// 3. 몬스터 HP > 70% & 활성 디버프 없음 → 디버프 스킬 /// 3. 30% 확률로 스킬 사용:
/// 4. 몬스터 HP > 50% & DOT 없음 → DOT 스킬 (장기전 유리) /// - 버프: HP > 80% & MP > 60% & 활성 버프 없음
/// 5. 보스전 (레벨 차이 10 이상) → 가장 강력한 공격 스킬 /// - 디버프: 몬스터 HP > 80% & 활성 디버프 없음
/// 6. 일반 전투 → MP 효율이 좋은 스킬 /// - DOT: 몬스터 HP > 60% & 활성 DOT 없음
/// 7. MP < 20% → null (일반 공격) /// - 공격: 보스전이면 강력한 스킬, 일반전이면 효율적 스킬
/// 4. MP < 20% → 일반 공격
Skill? selectAutoSkill({ Skill? selectAutoSkill({
required CombatStats player, required CombatStats player,
required MonsterCombatStats monster, required MonsterCombatStats monster,
@@ -336,39 +342,49 @@ class SkillService {
if (availableSkills.isEmpty) return null; if (availableSkills.isEmpty) return null;
// HP < 30% → 회복 스킬 우선 // HP < 30% → 회복 스킬 우선 (생존)
if (hpRatio < 0.3) { if (hpRatio < 0.3) {
final healSkill = _findBestHealSkill(availableSkills, currentMp); final healSkill = _findBestHealSkill(availableSkills, currentMp);
if (healSkill != null) return healSkill; if (healSkill != null) return healSkill;
} }
// HP > 70% & MP > 50% → 버프 스킬 (안전할 때) // 70% 확률로 일반 공격 (스킬은 특별한 상황에서만)
if (hpRatio > 0.7 && mpRatio > 0.5) { final useNormalAttack = rng.nextInt(100) < 70;
final buffSkill = _findBestBuffSkill(availableSkills, currentMp); if (useNormalAttack) return null;
if (buffSkill != null) return buffSkill;
// === 아래부터 30% 확률로 스킬 사용 ===
// 버프: HP > 80% & MP > 60% (매우 안전할 때만)
// 활성 버프가 있으면 건너뜀 (중복 방지)
if (hpRatio > 0.8 && mpRatio > 0.6) {
final hasActiveBuff = skillSystem.activeBuffs.isNotEmpty;
if (!hasActiveBuff) {
final buffSkill = _findBestBuffSkill(availableSkills, currentMp);
if (buffSkill != null) return buffSkill;
}
} }
// 몬스터 HP > 70% & 활성 디버프 없음 → 디버프 스킬 // 디버프: 몬스터 HP > 80% & 활성 디버프 없음 (전투 초반)
if (monster.hpRatio > 0.7 && activeDebuffs.isEmpty) { if (monster.hpRatio > 0.8 && activeDebuffs.isEmpty) {
final debuffSkill = _findBestDebuffSkill(availableSkills, currentMp); final debuffSkill = _findBestDebuffSkill(availableSkills, currentMp);
if (debuffSkill != null) return debuffSkill; if (debuffSkill != null) return debuffSkill;
} }
// 몬스터 HP > 50% & 활성 DOT 없음 → DOT 스킬 사용 // DOT: 몬스터 HP > 60% & 활성 DOT 없음 (장기전 유리)
if (monster.hpRatio > 0.5 && activeDoTs.isEmpty) { if (monster.hpRatio > 0.6 && activeDoTs.isEmpty) {
final dotSkill = _findBestDotSkill(availableSkills, currentMp); final dotSkill = _findBestDotSkill(availableSkills, currentMp);
if (dotSkill != null) return dotSkill; if (dotSkill != null) return dotSkill;
} }
// 보스전 판단 (몬스터 레벨이 높음) // 보스전 판단 (몬스터 레벨 20 이상 & HP 50% 이상)
final isBossFight = monster.level >= 10 && monster.hpRatio > 0.5; final isBossFight = monster.level >= 20 && monster.hpRatio > 0.5;
if (isBossFight) { if (isBossFight) {
// 가장 강력한 공격 스킬 // 가장 강력한 공격 스킬
return _findStrongestAttackSkill(availableSkills); return _findStrongestAttackSkill(availableSkills);
} }
// 일반 전투 → MP 효율 좋은 스킬 // 일반 전투 → MP 효율 좋은 공격 스킬
return _findEfficientAttackSkill(availableSkills); return _findEfficientAttackSkill(availableSkills);
} }

View File

@@ -8,6 +8,7 @@ import 'package:asciineverdie/src/core/model/item_stats.dart';
import 'package:asciineverdie/src/core/model/monster_grade.dart'; import 'package:asciineverdie/src/core/model/monster_grade.dart';
import 'package:asciineverdie/src/core/model/potion.dart'; import 'package:asciineverdie/src/core/model/potion.dart';
import 'package:asciineverdie/src/core/model/skill.dart'; import 'package:asciineverdie/src/core/model/skill.dart';
import 'package:asciineverdie/src/core/model/skill_slots.dart';
import 'package:asciineverdie/src/core/util/deterministic_random.dart'; import 'package:asciineverdie/src/core/util/deterministic_random.dart';
/// Minimal skeletal state to mirror Progress Quest structures. /// Minimal skeletal state to mirror Progress Quest structures.
@@ -193,14 +194,19 @@ enum DeathCause {
/// 스킬 시스템 상태 (Phase 3) /// 스킬 시스템 상태 (Phase 3)
/// ///
/// 스킬 쿨타임, 활성 버프, 게임 경과 시간 등을 관리 /// 스킬 쿨타임, 활성 버프, 게임 경과 시간, 장착 스킬 등을 관리
class SkillSystemState { class SkillSystemState {
const SkillSystemState({ const SkillSystemState({
required this.skillStates, required this.skillStates,
required this.activeBuffs, required this.activeBuffs,
required this.elapsedMs, required this.elapsedMs,
this.equippedSkills = const SkillSlots(),
this.globalCooldownEndMs = 0,
}); });
/// 글로벌 쿨타임 (GCD) 상수: 1500ms
static const int globalCooldownDuration = 1500;
/// 스킬별 쿨타임 상태 /// 스킬별 쿨타임 상태
final List<SkillState> skillStates; final List<SkillState> skillStates;
@@ -210,8 +216,26 @@ class SkillSystemState {
/// 게임 진행 경과 시간 (밀리초, 스킬 쿨타임 계산용) /// 게임 진행 경과 시간 (밀리초, 스킬 쿨타임 계산용)
final int elapsedMs; final int elapsedMs;
factory SkillSystemState.empty() => /// 장착된 스킬 슬롯 (타입별 제한 있음)
const SkillSystemState(skillStates: [], activeBuffs: [], elapsedMs: 0); final SkillSlots equippedSkills;
/// 글로벌 쿨타임 종료 시점 (elapsedMs 기준)
final int globalCooldownEndMs;
/// GCD가 활성화 중인지 확인
bool get isGlobalCooldownActive => elapsedMs < globalCooldownEndMs;
/// 남은 GCD 시간 (ms)
int get remainingGlobalCooldown =>
isGlobalCooldownActive ? globalCooldownEndMs - elapsedMs : 0;
factory SkillSystemState.empty() => const SkillSystemState(
skillStates: [],
activeBuffs: [],
elapsedMs: 0,
equippedSkills: SkillSlots(),
globalCooldownEndMs: 0,
);
/// 특정 스킬 상태 가져오기 /// 특정 스킬 상태 가져오기
SkillState? getSkillState(String skillId) { SkillState? getSkillState(String skillId) {
@@ -254,13 +278,22 @@ class SkillSystemState {
List<SkillState>? skillStates, List<SkillState>? skillStates,
List<ActiveBuff>? activeBuffs, List<ActiveBuff>? activeBuffs,
int? elapsedMs, int? elapsedMs,
SkillSlots? equippedSkills,
int? globalCooldownEndMs,
}) { }) {
return SkillSystemState( return SkillSystemState(
skillStates: skillStates ?? this.skillStates, skillStates: skillStates ?? this.skillStates,
activeBuffs: activeBuffs ?? this.activeBuffs, activeBuffs: activeBuffs ?? this.activeBuffs,
elapsedMs: elapsedMs ?? this.elapsedMs, elapsedMs: elapsedMs ?? this.elapsedMs,
equippedSkills: equippedSkills ?? this.equippedSkills,
globalCooldownEndMs: globalCooldownEndMs ?? this.globalCooldownEndMs,
); );
} }
/// GCD 시작 (스킬 사용 후 호출)
SkillSystemState startGlobalCooldown() {
return copyWith(globalCooldownEndMs: elapsedMs + globalCooldownDuration);
}
} }
/// 태스크 타입 (원본 fTask.Caption 값들에 대응) /// 태스크 타입 (원본 fTask.Caption 값들에 대응)
@@ -594,8 +627,11 @@ class Equipment {
} }
/// 초보자 방어구 생성 헬퍼 /// 초보자 방어구 생성 헬퍼
static EquipmentItem _starterArmor(String name, EquipmentSlot slot, static EquipmentItem _starterArmor(
{required int def}) { String name,
EquipmentSlot slot, {
required int def,
}) {
return EquipmentItem( return EquipmentItem(
name: name, name: name,
slot: slot, slot: slot,

View File

@@ -117,6 +117,7 @@ class Skill {
required this.mpCost, required this.mpCost,
required this.cooldownMs, required this.cooldownMs,
required this.power, required this.power,
this.tier = 1,
this.damageMultiplier = 1.0, this.damageMultiplier = 1.0,
this.healAmount = 0, this.healAmount = 0,
this.healPercent = 0.0, this.healPercent = 0.0,
@@ -133,6 +134,9 @@ class Skill {
this.mpHealAmount = 0, this.mpHealAmount = 0,
}); });
/// 스킬 티어 (1~5, 높을수록 강함)
final int tier;
/// 스킬 ID /// 스킬 ID
final String id; final String id;
@@ -213,6 +217,79 @@ class Skill {
if (type != SkillType.attack || damageMultiplier <= 0) return 0; if (type != SkillType.attack || damageMultiplier <= 0) return 0;
return damageMultiplier / mpCost; 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, onCooldown,
/// 글로벌 쿨타임(GCD) 중
onGlobalCooldown,
/// 스킬 없음 /// 스킬 없음
skillNotFound, 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,
}

View File

@@ -242,6 +242,14 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
_lastMonsterBaseName = widget.monsterBaseName; _lastMonsterBaseName = widget.monsterBaseName;
} }
// 새 몬스터 등장 시 사망 애니메이션 상태 리셋
// (이전 몬스터 사망 애니메이션이 끝나기 전에 새 전투 시작 시 대응)
if (oldWidget.monsterBaseName != widget.monsterBaseName &&
widget.monsterBaseName != null) {
_showDeathAnimation = false;
_deathAnimationMonsterLines = null;
}
if (oldWidget.taskType != widget.taskType || if (oldWidget.taskType != widget.taskType ||
oldWidget.monsterBaseName != widget.monsterBaseName || oldWidget.monsterBaseName != widget.monsterBaseName ||
oldWidget.weaponName != widget.weaponName || oldWidget.weaponName != widget.weaponName ||
@@ -550,6 +558,9 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
_battleSubFrame = 0; _battleSubFrame = 0;
_phaseIndex = 0; _phaseIndex = 0;
_phaseFrameCount = 0; _phaseFrameCount = 0;
// 새 전투 시작 시 사망 애니메이션 상태 리셋 (몬스터 숨김 방지)
_showDeathAnimation = false;
_deathAnimationMonsterLines = null;
} }
case AsciiAnimationType.town: case AsciiAnimationType.town:

View File

@@ -0,0 +1,260 @@
import 'dart:math';
import 'package:flutter_test/flutter_test.dart';
/// 글로벌 쿨타임(GCD) 시뮬레이션 테스트
///
/// 다양한 GCD 값에 대해 전투 효율성을 측정
void main() {
test('GCD 시뮬레이션 - 다양한 값 비교', () {
print('\n' + '=' * 80);
print('글로벌 쿨타임(GCD) 시뮬레이션 결과');
print('=' * 80);
// 테스트할 GCD 값들 (ms)
final gcdValues = [0, 500, 1000, 1500, 2000, 2500, 3000];
// 시뮬레이션 파라미터
const playerAttackDelay = 1000; // 플레이어 기본 공격 딜레이
const monsterAttackDelay = 1200; // 몬스터 공격 딜레이
const skillUseProbability = 0.30; // 스킬 사용 확률 (30%)
const normalAttackDamage = 50; // 일반 공격 데미지
const skillDamage = 120; // 스킬 데미지 (평균)
const monsterHp = 1000; // 몬스터 HP
const simulationRuns = 1000; // 시뮬레이션 반복 횟수
final results = <int, SimulationResult>{};
for (final gcd in gcdValues) {
final result = _runSimulation(
gcd: gcd,
playerAttackDelay: playerAttackDelay,
monsterAttackDelay: monsterAttackDelay,
skillUseProbability: skillUseProbability,
normalAttackDamage: normalAttackDamage,
skillDamage: skillDamage,
monsterHp: monsterHp,
runs: simulationRuns,
);
results[gcd] = result;
}
// 결과 출력
print('\n### 시뮬레이션 조건');
print('- 플레이어 공격 딜레이: ${playerAttackDelay}ms');
print('- 몬스터 HP: $monsterHp');
print('- 일반 공격 데미지: $normalAttackDamage');
print('- 스킬 데미지 (평균): $skillDamage');
print('- 스킬 사용 확률: ${(skillUseProbability * 100).toInt()}%');
print('- 시뮬레이션 횟수: $simulationRuns회');
print('\n### 결과 비교');
print('| GCD (ms) | 전투시간 (ms) | 총 공격 | 스킬 사용 | 스킬비율 | DPS | 효율 |');
print(
'|----------|---------------|---------|-----------|----------|-----|------|',
);
final baselineDps = results[0]!.avgDps;
for (final gcd in gcdValues) {
final r = results[gcd]!;
final efficiency = (r.avgDps / baselineDps * 100).toStringAsFixed(0);
print(
'| ${gcd.toString().padLeft(8)} '
'| ${r.avgCombatTime.toStringAsFixed(0).padLeft(13)} '
'| ${r.avgTotalAttacks.toStringAsFixed(1).padLeft(7)} '
'| ${r.avgSkillUses.toStringAsFixed(1).padLeft(9)} '
'| ${(r.skillRatio * 100).toStringAsFixed(1).padLeft(7)}% '
'| ${r.avgDps.toStringAsFixed(1).padLeft(3)} '
'| ${efficiency.padLeft(4)}% |',
);
}
// 권장 GCD 분석
print('\n### 분석 및 권장');
// GCD 1500ms 기준 분석
final gcd1500 = results[1500]!;
final gcd2000 = results[2000]!;
// gcd1000은 비교용으로 추후 활용 가능
print('\n#### GCD별 특징');
print('- **0ms (없음)**: 기준선, 스킬 남용 가능');
print('- **500ms**: 거의 제한 없음, 빠른 연속 스킬 가능');
print('- **1000ms**: 공격 딜레이와 동일, 매 공격마다 스킬 선택 가능');
print('- **1500ms**: 스킬 후 1회 일반공격 강제, 적절한 제한');
print('- **2000ms**: 스킬 후 2회 일반공격 강제, 스킬이 특별해짐');
print('- **2500ms+**: 스킬 사용이 매우 제한적, 전략적 선택 필요');
print('\n#### 권장 GCD');
print('**1500ms ~ 2000ms 권장**');
print('- 스킬 사용 빈도가 자연스럽게 제한됨');
print('- 일반 공격의 중요성이 유지됨');
print(
'- DPS 손실이 크지 않음 (${((1 - gcd1500.avgDps / baselineDps) * 100).toStringAsFixed(0)}% ~ ${((1 - gcd2000.avgDps / baselineDps) * 100).toStringAsFixed(0)}%)',
);
print('\n' + '=' * 80);
// 테스트는 항상 통과 (정보 출력용)
expect(true, isTrue);
});
test('GCD 상세 시뮬레이션 - 레벨별 영향', () {
print('\n' + '=' * 80);
print('레벨별 GCD 영향 분석');
print('=' * 80);
// 레벨별 파라미터
final levelConfigs = [
{'level': 1, 'playerAtk': 30, 'skillDmg': 80, 'monsterHp': 200},
{'level': 10, 'playerAtk': 50, 'skillDmg': 130, 'monsterHp': 500},
{'level': 30, 'playerAtk': 90, 'skillDmg': 220, 'monsterHp': 1500},
{'level': 50, 'playerAtk': 130, 'skillDmg': 320, 'monsterHp': 3000},
];
const gcdToTest = [0, 1500, 2000];
print('\n| 레벨 | GCD | 전투시간 | 스킬횟수 | DPS | 효율 |');
print('|------|-----|----------|----------|-----|------|');
for (final config in levelConfigs) {
final level = config['level'] as int;
final playerAtk = config['playerAtk'] as int;
final skillDmg = config['skillDmg'] as int;
final monsterHp = config['monsterHp'] as int;
double? baselineDps;
for (final gcd in gcdToTest) {
final result = _runSimulation(
gcd: gcd,
playerAttackDelay: 1000,
monsterAttackDelay: 1200,
skillUseProbability: 0.30,
normalAttackDamage: playerAtk,
skillDamage: skillDmg,
monsterHp: monsterHp,
runs: 500,
);
baselineDps ??= result.avgDps;
final efficiency = (result.avgDps / baselineDps * 100).toStringAsFixed(
0,
);
print(
'| ${level.toString().padLeft(4)} '
'| ${gcd.toString().padLeft(3)} '
'| ${result.avgCombatTime.toStringAsFixed(0).padLeft(8)} '
'| ${result.avgSkillUses.toStringAsFixed(1).padLeft(8)} '
'| ${result.avgDps.toStringAsFixed(1).padLeft(3)} '
'| ${efficiency.padLeft(4)}% |',
);
}
}
print('\n' + '=' * 80);
expect(true, isTrue);
});
}
/// 시뮬레이션 실행
SimulationResult _runSimulation({
required int gcd,
required int playerAttackDelay,
required int monsterAttackDelay,
required double skillUseProbability,
required int normalAttackDamage,
required int skillDamage,
required int monsterHp,
required int runs,
}) {
final random = Random(42); // 재현 가능한 시드
var totalCombatTime = 0;
var totalAttacks = 0;
var totalSkillUses = 0;
var totalDamage = 0;
for (var i = 0; i < runs; i++) {
var currentHp = monsterHp;
var elapsedTime = 0;
var attackCount = 0;
var skillCount = 0;
var gcdRemaining = 0; // 남은 GCD 시간
var playerAccum = 0;
while (currentHp > 0) {
// 시간 진행 (100ms 단위)
const tickMs = 100;
elapsedTime += tickMs;
playerAccum += tickMs;
// GCD 감소
if (gcdRemaining > 0) {
gcdRemaining -= tickMs;
if (gcdRemaining < 0) gcdRemaining = 0;
}
// 플레이어 공격 체크
if (playerAccum >= playerAttackDelay) {
playerAccum -= playerAttackDelay;
attackCount++;
// 스킬 사용 여부 결정
final canUseSkill = gcdRemaining <= 0;
final wantsToUseSkill = random.nextDouble() < skillUseProbability;
if (canUseSkill && wantsToUseSkill) {
// 스킬 사용
currentHp -= skillDamage;
totalDamage += skillDamage;
skillCount++;
gcdRemaining = gcd; // GCD 시작
} else {
// 일반 공격
currentHp -= normalAttackDamage;
totalDamage += normalAttackDamage;
}
}
// 무한 루프 방지
if (elapsedTime > 60000) break;
}
totalCombatTime += elapsedTime;
totalAttacks += attackCount;
totalSkillUses += skillCount;
}
final avgCombatTime = totalCombatTime / runs;
final avgTotalAttacks = totalAttacks / runs;
final avgSkillUses = totalSkillUses / runs;
final skillRatio = avgSkillUses / avgTotalAttacks;
final avgDps = totalDamage / runs / (avgCombatTime / 1000);
return SimulationResult(
avgCombatTime: avgCombatTime,
avgTotalAttacks: avgTotalAttacks,
avgSkillUses: avgSkillUses,
skillRatio: skillRatio,
avgDps: avgDps,
);
}
class SimulationResult {
const SimulationResult({
required this.avgCombatTime,
required this.avgTotalAttacks,
required this.avgSkillUses,
required this.skillRatio,
required this.avgDps,
});
final double avgCombatTime;
final double avgTotalAttacks;
final double avgSkillUses;
final double skillRatio;
final double avgDps;
}