Compare commits

...

12 Commits

Author SHA1 Message Date
JiWoong Sul
cf8fdaecde feat(ui): HP/MP 바 개선 및 전투 시스템 UI 업데이트
- HP/MP 변화 시 플래시 효과 및 변화량 표시 추가
- 전투 중 몬스터 HP 바 표시 기능 추가
- 몬스터 HP 바 Row 오버플로우 버그 수정 (Flexible 적용)
- 전투 상태 및 이벤트 모델 개선
- 캐릭터 애니메이션 및 전투 컴포저 업데이트
2025-12-18 18:10:22 +09:00
JiWoong Sul
45147da5ec fix(notification): Act 완료 토스트 메시지 인덱싱 버그 수정
- plotStageCount 인덱싱 오류 수정 (plotStageCount - 1 → -2)
- 프롤로그 완료 시 "PROLOGUE COMPLETE!" 표시되도록 수정
- showActComplete(0)이 프롤로그 완료를 올바르게 처리
2025-12-17 19:31:21 +09:00
JiWoong Sul
27e21bb064 fix(cinematic): 시네마틱/클리어 다이얼로그 표시 타이밍 수정
- 리스너 콜백 중 showDialog 호출로 인한 게임 멈춤 현상 수정
- addPostFrameCallback으로 다음 프레임에서 다이얼로그 표시
- 위젯 트리가 안정된 상태에서 showDialog 실행
2025-12-17 19:23:27 +09:00
JiWoong Sul
810bf6867d fix(game): 액트 종료 시 게임 멈춤 버그 수정
- 엔딩 Act에서 시네마틱과 클리어 처리가 동시에 호출되던 문제 수정
- 엔딩 도달 시 클리어 다이얼로그만 표시하도록 로직 분리
- 일반 Act 전환 시에만 시네마틱 표시
2025-12-17 19:17:47 +09:00
JiWoong Sul
9af5c4dc13 feat(hall): Phase 10 명예의 전당 시스템 구현
- HallOfFameEntry 모델 및 HallOfFame 컬렉션 추가
- HallOfFameStorage 저장소 (JSON 파일 기반)
- HallOfFameScreen UI (순위별 색상/아이콘)
- 게임 클리어 시 명예의 전당 등록 처리
- FrontScreen에 명예의 전당 버튼 추가
- 클리어 축하 다이얼로그 구현
2025-12-17 18:57:26 +09:00
JiWoong Sul
7c7f3b0d9e feat(ui): Phase 8 UI/UX 개선 완료
- CombatLog 위젯 게임 화면에 통합
- HP/MP 바 추가 (HP < 20% 깜빡임 효과)
- SkillPanel 추가 (쿨타임 완료 시 glow 효과)
- combatLog 로컬라이제이션 (4개 언어)
- 테스트 수정 (skipOffstage 처리)
2025-12-17 18:52:24 +09:00
JiWoong Sul
abcb89d334 feat(story): Phase 9 스토리/엔딩 시스템 구현
- story_data.dart: 5개 Act 스토리 텍스트 및 ASCII 아트
- story_service.dart: Act 전환/보스 조우/엔딩 이벤트 관리
- cinematic_view.dart: 풀스크린 시네마틱 UI (페이드, 스킵)
- game_play_screen.dart: 레벨 기반 Act 전환 시 시네마틱 재생
2025-12-17 18:38:08 +09:00
JiWoong Sul
8cbef3475b feat(ui): Phase 8 실시간 피드백 시스템 구현
- StatsPanel: 스탯 변화 애니메이션 (증감 표시)
- CombatLog: 전투 이벤트 로그 위젯
- NotificationService: 큐 기반 알림 관리
- NotificationOverlay: 레벨업/퀘스트 완료 팝업 알림
- GamePlayScreen: 새 위젯 통합
2025-12-17 18:33:21 +09:00
JiWoong Sul
bfcec44ac7 feat(content): Phase 7 콘텐츠 확장
- 몬스터 데이터 65종 추가 (총 369종)
  - 레벨 54-100 엔드게임 몬스터
  - 미니보스 10종, 보스 8종
- 아이템 데이터 확장 (총 440+종)
  - 무기, 방어구, 방패 고급 아이템
  - 주문, 속성, 보링 아이템 추가
- ASCII 애니메이션 카테고리 테마 변경
  - beast/insect/humanoid → bug/malware/network
  - undead/dragon/slime/demon → system/crypto/ai/boss
- 테스트 기대값 업데이트 (Phase 7 확장 반영)
2025-12-17 18:21:13 +09:00
JiWoong Sul
a6ba3d5d2e feat(phase7): 고정 4색 팔레트 시스템 적용
- ascii_colors.dart 생성
  - 흰색(object): 캐릭터, 몬스터, 아이템
  - 시안(positive): 힐, 버프, 레벨업, 획득
  - 마젠타(negative): 데미지, 디버프, 사망, 손실
  - 검정(background): 배경

- 테마 선택 기능 제거
  - AsciiAnimationCard: colorTheme 파라미터 제거, 고정 색상 사용
  - TaskProgressPanel: 테마 버튼 제거
  - GamePlayScreen: 테마 관련 상태/메서드 제거

- 이펙트 색상 시스템 업데이트
  - '*' (히트) → 마젠타
  - '!' '+' (강조/버프) → 시안
  - '~' (디버프) → 마젠타
2025-12-17 17:54:07 +09:00
JiWoong Sul
97d9875e00 feat(phase6): 밸런싱 상수 및 몬스터 스탯 스케일링 시스템 구현
- balance_constants.dart 생성
  - ExpConstants: 경험치 곡선 (baseExp * 1.15^level)
  - MonsterType: normal, elite, miniboss, boss, finalBoss
  - MonsterTypeMultiplier: 타입별 HP/ATK/DEF/EXP/GOLD 배율
  - MonsterBaseStats: 레벨 기반 몬스터 스탯 생성
  - BossStats: 5개 보스 정의 (Syntax Error Dragon ~ Glitch God)
  - LevelTierSettings: 구간별 난이도 설정 (초반/중반/후반/엔드게임)
  - PlayerScaling: 플레이어 레벨업 시 HP/MP 스케일링

- MonsterCombatStats 통합
  - MonsterBaseStats.generate() 사용하여 스탯 생성
  - monsterType 매개변수 추가로 타입별 몬스터 지원
2025-12-17 17:48:46 +09:00
JiWoong Sul
ec27389e9b feat(phase5): 종족/클래스 시스템 균형 및 UI 통합
- 21개 종족 균형 재설계 (스탯 합계 = 0)
- 18개 클래스 균형 재설계 (스탯 합계 = +3)
- Traits에 raceId, classId 필드 추가
- 저장/불러오기에 종족/클래스 ID 추가
- 캐릭터 생성 UI에서 RaceData/ClassData 사용
- 선택 시 스탯 보정 및 패시브 정보 표시
2025-12-17 17:42:27 +09:00
46 changed files with 5433 additions and 614 deletions

View File

@@ -12,7 +12,7 @@ class ClassData {
// 전사 계열 (STR 기반)
// ==========================================================================
/// Bug Hunter: STR + INT
/// Bug Hunter: STR + INT (스탯 합계: +3)
static const bugHunter = ClassTraits(
classId: 'bug_hunter',
name: 'Bug Hunter',
@@ -32,12 +32,13 @@ class ClassData {
restriction: EquipmentRestriction.none,
);
/// Overflow Warrior: STR
/// Overflow Warrior: STR + CON (스탯 합계: +3)
static const overflowWarrior = ClassTraits(
classId: 'overflow_warrior',
name: 'Overflow Warrior',
statModifiers: {
StatType.str: 2,
StatType.con: 1,
},
startingSkills: ['power_strike'],
classSkills: ['power_strike', 'overflow_slash', 'buffer_break'],
@@ -51,7 +52,7 @@ class ClassData {
restriction: EquipmentRestriction.none,
);
/// Stack Crusher: STR + CON
/// Stack Crusher: STR + CON (스탯 합계: +3)
static const stackCrusher = ClassTraits(
classId: 'stack_crusher',
name: 'Stack Crusher',
@@ -76,7 +77,7 @@ class ClassData {
restriction: EquipmentRestriction.none,
);
/// Assertion Knight: STR + WIS
/// Assertion Knight: STR + WIS (스탯 합계: +3)
static const assertionKnight = ClassTraits(
classId: 'assertion_knight',
name: 'Assertion Knight',
@@ -102,7 +103,7 @@ class ClassData {
// 탱커 계열 (CON 기반)
// ==========================================================================
/// Debugger Paladin: WIS + CON
/// Debugger Paladin: WIS + CON (스탯 합계: +3)
static const debuggerPaladin = ClassTraits(
classId: 'debugger_paladin',
name: 'Debugger Paladin',
@@ -129,12 +130,13 @@ class ClassData {
),
);
/// Loop Breaker: CON
/// Loop Breaker: CON + STR (스탯 합계: +3)
static const loopBreaker = ClassTraits(
classId: 'loop_breaker',
name: 'Loop Breaker',
statModifiers: {
StatType.con: 2,
StatType.str: 1,
},
startingSkills: ['shield_bash'],
classSkills: ['shield_bash', 'infinite_guard', 'break_stance'],
@@ -150,7 +152,7 @@ class ClassData {
),
);
/// Garbage Collector: CON + STR
/// Garbage Collector: CON + STR (스탯 합계: +3)
static const garbageCollector = ClassTraits(
classId: 'garbage_collector',
name: 'Garbage Collector',
@@ -181,12 +183,13 @@ class ClassData {
// 마법사 계열 (INT 기반)
// ==========================================================================
/// Compiler Mage: INT + MP Max
/// Compiler Mage: INT + WIS (스탯 합계: +3)
static const compilerMage = ClassTraits(
classId: 'compiler_mage',
name: 'Compiler Mage',
statModifiers: {
StatType.intelligence: 2,
StatType.wis: 1,
},
startingSkills: ['fireball'],
classSkills: ['fireball', 'compile_blast', 'syntax_storm'],
@@ -202,12 +205,13 @@ class ClassData {
),
);
/// Recursion Master: INT
/// Recursion Master: INT + DEX (스탯 합계: +3)
static const recursionMaster = ClassTraits(
classId: 'recursion_master',
name: 'Recursion Master',
statModifiers: {
StatType.intelligence: 2,
StatType.dex: 1,
},
startingSkills: ['fireball'],
classSkills: ['fireball', 'recursive_bolt', 'stack_overflow'],
@@ -223,7 +227,7 @@ class ClassData {
),
);
/// Memory Leaker: INT + WIS
/// Memory Leaker: INT + WIS (스탯 합계: +3)
static const memoryLeaker = ClassTraits(
classId: 'memory_leaker',
name: 'Memory Leaker',
@@ -245,7 +249,7 @@ class ClassData {
),
);
/// Type Caster: INT + CHA
/// Type Caster: INT + CHA (스탯 합계: +3)
static const typeCaster = ClassTraits(
classId: 'type_caster',
name: 'Type Caster',
@@ -267,7 +271,7 @@ class ClassData {
),
);
/// DevOps Shaman: CON + INT
/// DevOps Shaman: CON + INT (스탯 합계: +3)
static const devOpsShaman = ClassTraits(
classId: 'devops_shaman',
name: 'DevOps Shaman',
@@ -298,12 +302,13 @@ class ClassData {
// 민첩 계열 (DEX 기반)
// ==========================================================================
/// Refactor Monk: DEX
/// Refactor Monk: DEX + CON (스탯 합계: +3)
static const refactorMonk = ClassTraits(
classId: 'refactor_monk',
name: 'Refactor Monk',
statModifiers: {
StatType.dex: 2,
StatType.con: 1,
},
startingSkills: ['flurry'],
classSkills: ['flurry', 'clean_code_strike', 'refactor_combo'],
@@ -324,12 +329,13 @@ class ClassData {
),
);
/// Pointer Assassin: DEX
/// Pointer Assassin: DEX + STR (스탯 합계: +3)
static const pointerAssassin = ClassTraits(
classId: 'pointer_assassin',
name: 'Pointer Assassin',
statModifiers: {
StatType.dex: 2,
StatType.str: 1,
},
startingSkills: ['backstab'],
classSkills: ['backstab', 'null_strike', 'dereference_kill'],
@@ -350,7 +356,7 @@ class ClassData {
),
);
/// Callback Samurai: DEX + STR
/// Callback Samurai: DEX + STR (스탯 합계: +3)
static const callbackSamurai = ClassTraits(
classId: 'callback_samurai',
name: 'Callback Samurai',
@@ -375,7 +381,7 @@ class ClassData {
restriction: EquipmentRestriction.none,
);
/// Tester Jester: DEX + CHA
/// Tester Jester: DEX + CHA (스탯 합계: +3)
static const testerJester = ClassTraits(
classId: 'tester_jester',
name: 'Tester Jester',
@@ -406,12 +412,13 @@ class ClassData {
// 지혜 계열 (WIS 기반)
// ==========================================================================
/// Exception Handler: WIS
/// Exception Handler: WIS + INT (스탯 합계: +3)
static const exceptionHandler = ClassTraits(
classId: 'exception_handler',
name: 'Exception Handler',
statModifiers: {
StatType.wis: 2,
StatType.intelligence: 1,
},
startingSkills: ['heal'],
classSkills: ['heal', 'try_catch', 'finally_heal'],
@@ -427,7 +434,7 @@ class ClassData {
),
);
/// Null Checker: WIS + INT
/// Null Checker: WIS + INT (스탯 합계: +3)
static const nullChecker = ClassTraits(
classId: 'null_checker',
name: 'Null Checker',

View File

@@ -23,6 +23,12 @@ const Map<String, List<String>> pqConfigData = {
'Virtualization Mail|20',
'Quantum Encryption|25',
'Zero-Day Aegis|30',
'Blockchain Platemail|35',
'Neural Network Mesh|40',
'AI Firewall|45',
'Quantum Shield Matrix|50',
'Singularity Barrier|55',
'Multiverse Armor|60',
],
'BoringItems': [
'semicolon',
@@ -66,6 +72,31 @@ const Map<String, List<String>> pqConfigData = {
'return value',
'exit code',
'errno',
'pid file',
'lock file',
'temp file',
'swap file',
'cache entry',
'cookie crumb',
'session token',
'nonce value',
'salt grain',
'pepper flake',
'iv fragment',
'padding byte',
'magic number',
'checksum bit',
'parity bit',
'stop bit',
'start bit',
'frame marker',
'sync byte',
'escape char',
'control char',
'printable char',
'ascii art',
'unicode glyph',
'emoji fragment',
],
'DefenseAttrib': [
'Patched|+1',
@@ -143,6 +174,25 @@ const Map<String, List<String>> pqConfigData = {
'Hashed',
'Signed',
'Verified',
'Containerized',
'Orchestrated',
'Scalable',
'Resilient',
'Fault-Tolerant',
'Self-Healing',
'Auto-Scaling',
'Load-Balanced',
'Cached',
'Indexed',
'Sharded',
'Replicated',
'Partitioned',
'Compressed',
'Encrypted',
'Tokenized',
'Anonymized',
'Sanitized',
'Validated',
],
'ItemOfs': [
'Compilation',
@@ -194,6 +244,27 @@ const Map<String, List<String>> pqConfigData = {
'the Runtime',
'the Compiler',
'the Kernel',
'Microservices',
'Serverless',
'Edge Computing',
'Fog Computing',
'Cloud Native',
'DevOps',
'Site Reliability',
'Platform Engineering',
'Infrastructure',
'Observability',
'Telemetry',
'Tracing',
'Metrics',
'Alerting',
'Incident Response',
'Chaos Engineering',
'Resilience',
'Availability',
'Durability',
'Consistency',
'Partition Tolerance',
],
'Klasses': [
'Bug Hunter|STR,INT',
@@ -553,6 +624,83 @@ const Map<String, List<String>> pqConfigData = {
'PwnKit|16|polkit priv',
'Sudo Bug|14|privilege escape',
'Baron Samedit|13|heap overflow',
// 레벨 54-65: 고급 시스템 위협 (Advanced System Threats)
'Kernel Exploiter|54|privilege token',
'Ring -1 Phantom|55|hypervisor breach',
'TPM Bypasser|56|trusted module',
'Secure Boot Breaker|57|boot chain',
'IOMMU Escape|58|memory isolation',
'SGX Enclave Bug|59|secure enclave',
'TrustZone Breach|60|arm security',
'Platform Security Bug|61|firmware key',
'Hardware Backdoor|62|silicon implant',
'Supply Chain Implant|63|factory malware',
'BMC Rootkit|64|baseboard mgmt',
'IPMI Ghost|65|remote mgmt',
// 레벨 66-80: 엔터프라이즈급 위협 (Enterprise Threats)
'Active Directory Worm|66|domain token',
'Kerberos Golden|67|ticket forgery',
'NTLM Relay Beast|68|auth bypass',
'DCSync Phantom|69|replication attack',
'Exchange Exploit|70|mail server',
'SharePoint Bug|71|collab breach',
'Teams Vulnerability|72|comm exploit',
'Azure AD Breach|73|cloud identity',
'AWS IAM Bug|74|cloud permission',
'GCP Exploit|75|google cloud',
'Kubernetes Escape|76|container breach',
'Docker Breakout|77|namespace escape',
'Service Mesh Bug|78|istio envoy',
'Terraform State Bug|79|infra code',
'CI/CD Pipeline Poison|80|build compromise',
// 레벨 81-90: 엔드게임 몬스터 (Endgame Monsters)
'Quantum Decoherence|81|qubit collapse',
'Neural Network Poison|82|model corrupt',
'AI Hallucination|83|false output',
'Deep Fake Engine|84|synthetic media',
'Adversarial Noise|85|ml attack',
'Model Extraction|86|stolen weights',
'Prompt Injection|87|llm exploit',
'Training Data Poison|88|dataset corrupt',
'Federated Learning Bug|89|distributed ml',
'Differential Privacy Leak|90|anonymity breach',
// 레벨 91-100: 최종 엔드게임 (Final Endgame)
'Post-Quantum Threat|91|lattice attack',
'Homomorphic Crack|92|encrypted compute',
'Zero Knowledge Flaw|93|proof bypass',
'Blockchain Fork|94|consensus break',
'Smart Contract Bug|95|solidity exploit',
'MEV Extractor|96|transaction reorder',
'Cross-Chain Bridge Bug|97|bridge exploit',
'Oracle Manipulation|98|price feed',
'Flash Loan Attack|99|defi exploit',
'The Final Bug|100|ultimate error',
// 미니보스 (Mini Bosses) - 특수 접두사로 구분
'Elite Syntax Overlord|25|syntax crown',
'Champion Buffer Crusher|30|overflow gem',
'Veteran Memory Lord|35|leak artifact',
'Master Race Conductor|40|thread scepter',
'Arch Kernel Breaker|45|ring zero',
'High Protocol Corruptor|50|packet throne',
'Grand Firmware Defiler|55|boot artifact',
'Supreme Cloud Invader|60|cloud crown',
'Legendary Container Escapist|65|namespace key',
'Ancient Pipeline Poisoner|70|build shard',
// 보스 몬스터 (Boss Monsters) - 고유 보스
'BOSS: Stack Overflow Dragon|35|legendary stack',
'BOSS: Heap Corruption Hydra|45|multi-head leak',
'BOSS: Kernel Panic Titan|55|system crash',
'BOSS: Zero Day Leviathan|65|unknown vuln',
'BOSS: APT Colossus|75|state actor',
'BOSS: Ransomware Emperor|85|encrypted realm',
'BOSS: AI Singularity|95|machine god',
'BOSS: The Primordial Glitch|100|genesis bug',
],
'OffenseAttrib': [
'Compiled|+1',
@@ -618,6 +766,13 @@ const Map<String, List<String>> pqConfigData = {
'Air Gap Shield|11',
'Faraday Shield|12',
'Quantum Firewall|18',
'Neural Defense Grid|22',
'Singularity Absorber|28',
'Time Dilation Field|35',
'Reality Anchor|42',
'Multiverse Barrier|50',
'Cosmic Dampener|58',
'Entropy Shield|65',
],
'Specials': [
'Algorithm',
@@ -702,6 +857,31 @@ const Map<String, List<String>> pqConfigData = {
'Deploy',
'Scale Up',
'Failover',
'Containerize',
'Orchestrate',
'Auto Scale',
'Load Balance',
'Cache Invalidate',
'Async Await',
'Promise Resolve',
'Thread Pool',
'Connection Pool',
'Circuit Break',
'Rate Limit',
'Retry Logic',
'Backpressure',
'Event Source',
'State Machine',
'Saga Pattern',
'CQRS Split',
'Event Store',
'Snapshot Restore',
'Blue Green Deploy',
'Canary Release',
'Feature Toggle',
'A/B Test',
'Chaos Monkey',
'Dark Launch',
],
'Titles': [
'Dev',
@@ -753,5 +933,16 @@ const Map<String, List<String>> pqConfigData = {
'Dyson Sphere Core|11',
'Black Hole Computer|12',
'Universe Simulator|15',
'Dimensional Gateway|20',
'Time Loop Device|25',
'Reality Compiler|30',
'Multiverse Bridge|35',
'Cosmic Debugger|40',
'Entropy Reverser|45',
'Big Bang Trigger|50',
'Heat Death Preventer|55',
'Infinity Engine|60',
'Omniscience Module|65',
'God Mode Activator|70',
],
};

View File

@@ -2,20 +2,42 @@ import 'package:askiineverdie/src/core/model/race_traits.dart';
/// 종족 데이터 정의 (race data)
///
/// 프로그래밍 테마의 21가지 종족 정의
/// pq_config_data.dart의 Races 데이터 기반
/// 21가지 종족 - 모든 종족의 스탯 합계 = 0 (균형)
/// 패시브는 각 종족의 고유한 플레이스타일을 정의
class RaceData {
RaceData._();
// ==========================================================================
// 기본 종족 (HP/밸런스형)
// HP/균형형 종족
// ==========================================================================
/// Byte Human: HP 보너스
/// Byte Human: 균형형 (스탯 합계: 0)
/// 특화 없이 경험치로 보상
static const byteHuman = RaceTraits(
raceId: 'byte_human',
name: 'Byte Human',
statModifiers: {},
passives: [
PassiveAbility(
type: PassiveType.expBonus,
value: 0.05,
description: '경험치 +5%',
),
],
expMultiplier: 1.05,
);
/// Kernel Giant: 탱커형 (스탯 합계: 0)
/// STR/CON +2, DEX/INT -2
static const kernelGiant = RaceTraits(
raceId: 'kernel_giant',
name: 'Kernel Giant',
statModifiers: {
StatType.str: 2,
StatType.con: 2,
StatType.dex: -2,
StatType.intelligence: -2,
},
passives: [
PassiveAbility(
type: PassiveType.hpBonus,
@@ -25,45 +47,32 @@ class RaceData {
],
);
/// Kernel Giant: STR + HP 보너스
static const kernelGiant = RaceTraits(
raceId: 'kernel_giant',
name: 'Kernel Giant',
statModifiers: {
StatType.str: 3,
StatType.dex: -2,
},
passives: [
PassiveAbility(
type: PassiveType.hpBonus,
value: 0.15,
description: 'HP +15%',
),
],
);
// ==========================================================================
// 지혜 종족 (WIS 기반)
// 지혜/마법 종족 (WIS 기반)
// ==========================================================================
/// Null Elf: WIS 보너스
/// Null Elf: 마법형 (스탯 합계: 0)
/// WIS/INT +2, STR/CON -2
static const nullElf = RaceTraits(
raceId: 'null_elf',
name: 'Null Elf',
statModifiers: {
StatType.wis: 2,
StatType.intelligence: 1,
StatType.str: -2,
StatType.con: -1,
},
passives: [
PassiveAbility(
type: PassiveType.magicDamageBonus,
value: 0.10,
description: '마법 데미지 +10%',
value: 0.08,
description: '마법 데미지 +8%',
),
],
);
/// Recursive Sage: WIS + INT 보너스
/// Recursive Sage: 순수 마법형 (스탯 합계: 0)
/// WIS/INT +2, STR -2, DEX -1, CHA +1
static const recursiveSage = RaceTraits(
raceId: 'recursive_sage',
name: 'Recursive Sage',
@@ -71,6 +80,8 @@ class RaceData {
StatType.wis: 2,
StatType.intelligence: 2,
StatType.str: -2,
StatType.dex: -1,
StatType.con: -1,
},
passives: [
PassiveAbility(
@@ -81,47 +92,62 @@ class RaceData {
],
);
/// Callback Priest: WIS + CHA 보너스
/// Callback Priest: 지원형 (스탯 합계: 0)
/// WIS +2, CHA +1, STR -1, DEX -1, CON -1
static const callbackPriest = RaceTraits(
raceId: 'callback_priest',
name: 'Callback Priest',
statModifiers: {
StatType.wis: 2,
StatType.cha: 2,
StatType.cha: 1,
StatType.str: -1,
StatType.dex: -1,
StatType.con: -1,
},
passives: [],
passives: [
PassiveAbility(
type: PassiveType.expBonus,
value: 0.03,
description: '경험치 +3%',
),
],
expMultiplier: 1.03,
);
// ==========================================================================
// 체력 종족 (CON 기반)
// 체력/방어 종족 (CON 기반)
// ==========================================================================
/// Buffer Dwarf: CON 보너스
/// Buffer Dwarf: 방어형 (스탯 합계: 0)
/// CON +2, STR +1, DEX -2, CHA -1
static const bufferDwarf = RaceTraits(
raceId: 'buffer_dwarf',
name: 'Buffer Dwarf',
statModifiers: {
StatType.con: 2,
StatType.dex: -1,
StatType.str: 1,
StatType.dex: -2,
StatType.cha: -1,
},
passives: [
PassiveAbility(
type: PassiveType.defenseBonus,
value: 0.05,
description: '방어력 +5%',
value: 0.08,
description: '방어력 +8%',
),
],
);
/// Coredump Undead: CON 보너스
/// Coredump Undead: 생존형 (스탯 합계: 0)
/// CON +2, STR +1, CHA -2, DEX -1
static const coredumpUndead = RaceTraits(
raceId: 'coredump_undead',
name: 'Coredump Undead',
statModifiers: {
StatType.con: 2,
StatType.str: 1,
StatType.cha: -2,
StatType.dex: -1,
},
passives: [
PassiveAbility(
@@ -133,16 +159,19 @@ class RaceData {
);
// ==========================================================================
// 민첩 종족 (DEX 기반)
// 민첩/크리티컬 종족 (DEX 기반)
// ==========================================================================
/// Bit Halfling: DEX 보너스
/// Bit Halfling: 민첩형 (스탯 합계: 0)
/// DEX +2, CHA +1, STR -2, CON -1
static const bitHalfling = RaceTraits(
raceId: 'bit_halfling',
name: 'Bit Halfling',
statModifiers: {
StatType.dex: 2,
StatType.str: -1,
StatType.cha: 1,
StatType.str: -2,
StatType.con: -1,
},
passives: [
PassiveAbility(
@@ -153,69 +182,81 @@ class RaceData {
],
);
/// Cache Imp: DEX 보너스
/// Cache Imp: 속도형 (스탯 합계: 0)
/// DEX +2, INT +1, CON -2, WIS -1
static const cacheImp = RaceTraits(
raceId: 'cache_imp',
name: 'Cache Imp',
statModifiers: {
StatType.dex: 2,
StatType.con: -1,
},
passives: [],
);
/// Iterator Rogue: DEX 보너스
static const iteratorRogue = RaceTraits(
raceId: 'iterator_rogue',
name: 'Iterator Rogue',
statModifiers: {
StatType.dex: 2,
StatType.intelligence: 1,
StatType.con: -2,
StatType.wis: -1,
},
passives: [
PassiveAbility(
type: PassiveType.criticalBonus,
value: 0.05,
description: '크리티컬 +5%',
value: 0.02,
description: '크리티컬 +2%',
),
],
);
/// Iterator Rogue: 암살형 (스탯 합계: 0)
/// DEX +3, STR +1, CON -2, WIS -1, CHA -1
static const iteratorRogue = RaceTraits(
raceId: 'iterator_rogue',
name: 'Iterator Rogue',
statModifiers: {
StatType.dex: 3,
StatType.str: 1,
StatType.con: -2,
StatType.wis: -1,
StatType.cha: -1,
},
passives: [
PassiveAbility(
type: PassiveType.criticalBonus,
value: 0.04,
description: '크리티컬 +4%',
),
],
);
// ==========================================================================
// 힘 종족 (STR 기반)
// 힘/물리 종족 (STR 기반)
// ==========================================================================
/// Array Orc: STR 보너스
/// Array Orc: 공격형 (스탯 합계: 0)
/// STR +2, CON +1, INT -2, WIS -1
static const arrayOrc = RaceTraits(
raceId: 'array_orc',
name: 'Array Orc',
statModifiers: {
StatType.str: 2,
StatType.intelligence: -1,
StatType.con: 1,
StatType.intelligence: -2,
StatType.wis: -1,
},
passives: [],
passives: [
PassiveAbility(
type: PassiveType.hpBonus,
value: 0.05,
description: 'HP +5%',
),
],
);
/// Flag Knight: CHA + STR 보너스
/// Flag Knight: 전사형 (스탯 합계: 0)
/// STR +2, CHA +1, INT -2, WIS -1
static const flagKnight = RaceTraits(
raceId: 'flag_knight',
name: 'Flag Knight',
statModifiers: {
StatType.cha: 2,
StatType.str: 2,
StatType.cha: 1,
StatType.intelligence: -2,
},
passives: [],
);
/// Protocol Paladin: STR + CHA 보너스
static const protocolPaladin = RaceTraits(
raceId: 'protocol_paladin',
name: 'Protocol Paladin',
statModifiers: {
StatType.str: 2,
StatType.cha: 2,
StatType.dex: -2,
StatType.wis: -1,
},
passives: [
PassiveAbility(
@@ -226,23 +267,53 @@ class RaceData {
],
);
/// Protocol Paladin: 수호자형 (스탯 합계: 0)
/// STR +2, CON +1, CHA +1, DEX -2, INT -2
static const protocolPaladin = RaceTraits(
raceId: 'protocol_paladin',
name: 'Protocol Paladin',
statModifiers: {
StatType.str: 2,
StatType.con: 1,
StatType.cha: 1,
StatType.dex: -2,
StatType.intelligence: -2,
},
passives: [
PassiveAbility(
type: PassiveType.defenseBonus,
value: 0.06,
description: '방어력 +6%',
),
],
);
// ==========================================================================
// 복합 스탯 종족
// ==========================================================================
/// Stack Goblin: DEX + CON 보너스
/// Stack Goblin: 기동형 (스탯 합계: 0)
/// DEX +2, CON +1, STR -1, CHA -2
static const stackGoblin = RaceTraits(
raceId: 'stack_goblin',
name: 'Stack Goblin',
statModifiers: {
StatType.dex: 2,
StatType.con: 1,
StatType.str: -1,
StatType.cha: -2,
},
passives: [],
passives: [
PassiveAbility(
type: PassiveType.criticalBonus,
value: 0.02,
description: '크리티컬 +2%',
),
],
);
/// Heap Troll: CON + STR 보너스
/// Heap Troll: 중장갑형 (스탯 합계: 0)
/// CON +2, STR +2, INT -2, DEX -2
static const heapTroll = RaceTraits(
raceId: 'heap_troll',
name: 'Heap Troll',
@@ -250,18 +321,19 @@ class RaceData {
StatType.con: 2,
StatType.str: 2,
StatType.intelligence: -2,
StatType.dex: -1,
StatType.dex: -2,
},
passives: [
PassiveAbility(
type: PassiveType.hpBonus,
value: 0.10,
description: 'HP +10%',
value: 0.12,
description: 'HP +12%',
),
],
);
/// Index Ranger: DEX + CON 보너스
/// Index Ranger: 정찰형 (스탯 합계: 0)
/// DEX +2, CON +1, INT -1, CHA -2
static const indexRanger = RaceTraits(
raceId: 'index_ranger',
name: 'Index Ranger',
@@ -269,21 +341,70 @@ class RaceData {
StatType.dex: 2,
StatType.con: 1,
StatType.intelligence: -1,
StatType.cha: -2,
},
passives: [],
passives: [
PassiveAbility(
type: PassiveType.criticalBonus,
value: 0.03,
description: '크리티컬 +3%',
),
],
);
// ==========================================================================
// 마법 종족 (MP/INT 기반)
// MP/주문 종족 (INT/MP 기반)
// ==========================================================================
/// Pointer Fairy: MP Max + WIS 보너스
/// Pointer Fairy: MP 특화형 (스탯 합계: 0)
/// WIS +2, INT +1, STR -2, CON -1
static const pointerFairy = RaceTraits(
raceId: 'pointer_fairy',
name: 'Pointer Fairy',
statModifiers: {
StatType.wis: 2,
StatType.intelligence: 1,
StatType.str: -2,
StatType.con: -1,
},
passives: [
PassiveAbility(
type: PassiveType.mpBonus,
value: 0.12,
description: 'MP +12%',
),
],
);
/// Register Gnome: 지능형 (스탯 합계: 0)
/// INT +2, DEX +1, STR -2, CON -1
static const registerGnome = RaceTraits(
raceId: 'register_gnome',
name: 'Register Gnome',
statModifiers: {
StatType.intelligence: 2,
StatType.dex: 1,
StatType.str: -2,
StatType.con: -1,
},
passives: [
PassiveAbility(
type: PassiveType.magicDamageBonus,
value: 0.05,
description: '마법 데미지 +5%',
),
],
);
/// Thread Spirit: 영체형 (스탯 합계: 0)
/// INT +1, WIS +1, CON -2
static const threadSpirit = RaceTraits(
raceId: 'thread_spirit',
name: 'Thread Spirit',
statModifiers: {
StatType.intelligence: 1,
StatType.wis: 1,
StatType.con: -2,
},
passives: [
PassiveAbility(
@@ -294,57 +415,34 @@ class RaceData {
],
);
/// Register Gnome: INT 보너스
static const registerGnome = RaceTraits(
raceId: 'register_gnome',
name: 'Register Gnome',
statModifiers: {
StatType.intelligence: 2,
StatType.str: -1,
},
passives: [],
);
/// Thread Spirit: MP Max 보너스
static const threadSpirit = RaceTraits(
raceId: 'thread_spirit',
name: 'Thread Spirit',
statModifiers: {
StatType.con: -1,
},
passives: [
PassiveAbility(
type: PassiveType.mpBonus,
value: 0.20,
description: 'MP +20%',
),
],
);
/// Loop Wizard: INT + MP Max 보너스
/// Loop Wizard: 순환 마법형 (스탯 합계: 0)
/// INT +2, WIS +1, STR -1, CON -1, DEX -1
static const loopWizard = RaceTraits(
raceId: 'loop_wizard',
name: 'Loop Wizard',
statModifiers: {
StatType.intelligence: 2,
StatType.wis: 1,
StatType.str: -1,
StatType.con: -1,
StatType.dex: -1,
},
passives: [
PassiveAbility(
type: PassiveType.mpBonus,
value: 0.10,
description: 'MP +10%',
value: 0.08,
description: 'MP +8%',
),
PassiveAbility(
type: PassiveType.magicDamageBonus,
value: 0.05,
description: '마법 데미지 +5%',
value: 0.04,
description: '마법 데미지 +4%',
),
],
);
/// Lambda Druid: INT + WIS 보너스
/// Lambda Druid: 자연 마법형 (스탯 합계: 0)
/// INT +2, WIS +2, STR -2, CON -2
static const lambdaDruid = RaceTraits(
raceId: 'lambda_druid',
name: 'Lambda Druid',
@@ -352,20 +450,25 @@ class RaceData {
StatType.intelligence: 2,
StatType.wis: 2,
StatType.str: -2,
StatType.con: -1,
StatType.con: -2,
},
passives: [
PassiveAbility(
type: PassiveType.magicDamageBonus,
value: 0.10,
description: '마법 데미지 +10%',
value: 0.06,
description: '마법 데미지 +6%',
),
PassiveAbility(
type: PassiveType.mpBonus,
value: 0.06,
description: 'MP +6%',
),
],
);
/// 모든 종족 목록 (21개)
static const List<RaceTraits> all = [
// 기본/HP
// 균형
byteHuman,
kernelGiant,
// 지혜형

395
lib/data/story_data.dart Normal file
View File

@@ -0,0 +1,395 @@
/// Phase 9: 스토리 데이터 (Story Data)
///
/// 프롤로그부터 엔딩까지 일관된 스토리 텍스트 정의
library;
/// 스토리 Act 정의
enum StoryAct {
prologue, // 프롤로그
act1, // Act I: 각성 (레벨 1-20)
act2, // Act II: 성장 (레벨 21-40)
act3, // Act III: 시련 (레벨 41-60)
act4, // Act IV: 결전 (레벨 61-80)
act5, // Act V: 종말 (레벨 81-100)
ending, // 엔딩
}
/// Act별 레벨 범위 (Level Range)
const Map<StoryAct, (int, int)> actLevelRange = {
StoryAct.prologue: (1, 1),
StoryAct.act1: (1, 20),
StoryAct.act2: (21, 40),
StoryAct.act3: (41, 60),
StoryAct.act4: (61, 80),
StoryAct.act5: (81, 100),
StoryAct.ending: (100, 999),
};
/// 레벨로 현재 Act 계산 (Calculate Current Act from Level)
StoryAct getActForLevel(int level) {
if (level <= 0) return StoryAct.prologue;
if (level <= 20) return StoryAct.act1;
if (level <= 40) return StoryAct.act2;
if (level <= 60) return StoryAct.act3;
if (level <= 80) return StoryAct.act4;
if (level < 100) return StoryAct.act5;
return StoryAct.ending;
}
/// 시네마틱 단계 (Cinematic Step)
class CinematicStep {
const CinematicStep({
required this.text,
this.asciiArt,
this.durationMs = 3000,
});
final String text;
final String? asciiArt;
final int durationMs;
}
/// Act별 시네마틱 데이터 (Cinematic Data per Act)
const Map<StoryAct, List<CinematicStep>> cinematicData = {
// 프롤로그: 코드의 신으로부터 비전을 받음
StoryAct.prologue: [
CinematicStep(
text: 'In the beginning, there was only the Void...',
asciiArt: _asciiVoid,
durationMs: 4000,
),
CinematicStep(
text: 'Then came the First Commit, and Light filled the Codebase.',
asciiArt: _asciiLight,
durationMs: 4000,
),
CinematicStep(
text: 'The Code God spoke: "Let there be Functions."',
durationMs: 3500,
),
CinematicStep(
text: 'And so the Digital Realm was born...',
durationMs: 3000,
),
CinematicStep(
text: 'But from the shadows emerged the Glitch.',
asciiArt: _asciiGlitch,
durationMs: 4000,
),
CinematicStep(
text: 'Now, a new hero awakens to defend the Code.',
durationMs: 3500,
),
CinematicStep(
text: 'Your journey begins...',
durationMs: 2500,
),
],
// Act I: 각성 (레벨 1-20)
StoryAct.act1: [
CinematicStep(
text: '=== ACT I: AWAKENING ===',
durationMs: 3000,
),
CinematicStep(
text: 'You have proven yourself against the lesser bugs.',
durationMs: 3000,
),
CinematicStep(
text: 'The Debugger Knights take notice of your potential.',
durationMs: 3500,
),
CinematicStep(
text: 'But a greater threat lurks in the Bug Nest...',
asciiArt: _asciiBugNest,
durationMs: 4000,
),
CinematicStep(
text: 'The Syntax Error Dragon awaits.',
asciiArt: _asciiDragon,
durationMs: 4000,
),
],
// Act II: 성장 (레벨 21-40)
StoryAct.act2: [
CinematicStep(
text: '=== ACT II: GROWTH ===',
durationMs: 3000,
),
CinematicStep(
text: 'With the Dragon slain, you join the Debugger Knights.',
durationMs: 3500,
),
CinematicStep(
text: 'The Corrupted Network spreads its infection...',
asciiArt: _asciiNetwork,
durationMs: 4000,
),
CinematicStep(
text: 'A traitor among the Knights is revealed!',
durationMs: 3500,
),
CinematicStep(
text: 'The Memory Leak Hydra threatens all data.',
asciiArt: _asciiHydra,
durationMs: 4000,
),
CinematicStep(
text: 'You must stop the corruption before it consumes everything.',
durationMs: 3500,
),
],
// Act III: 시련 (레벨 41-60)
StoryAct.act3: [
CinematicStep(
text: '=== ACT III: TRIALS ===',
durationMs: 3000,
),
CinematicStep(
text: 'The path leads to the Null Kingdom...',
asciiArt: _asciiNullKingdom,
durationMs: 4000,
),
CinematicStep(
text: 'The Ancient Compiler challenges you to its trials.',
durationMs: 3500,
),
CinematicStep(
text: 'A companion falls... their sacrifice not in vain.',
durationMs: 4000,
),
CinematicStep(
text: 'The Buffer Overflow Titan guards the gate.',
asciiArt: _asciiTitan,
durationMs: 4000,
),
CinematicStep(
text: 'Only through great sacrifice can you proceed.',
durationMs: 3500,
),
],
// Act IV: 결전 (레벨 61-80)
StoryAct.act4: [
CinematicStep(
text: '=== ACT IV: CONFRONTATION ===',
durationMs: 3000,
),
CinematicStep(
text: "The Glitch God's Citadel looms before you.",
asciiArt: _asciiCitadel,
durationMs: 4000,
),
CinematicStep(
text: 'Former enemies unite against the common threat.',
durationMs: 3500,
),
CinematicStep(
text: 'The Final Alliance is forged.',
durationMs: 3000,
),
CinematicStep(
text: 'The Kernel Panic Archon blocks your path.',
asciiArt: _asciiArchon,
durationMs: 4000,
),
CinematicStep(
text: 'One final battle before the end...',
durationMs: 3500,
),
],
// Act V: 종말 (레벨 81-100)
StoryAct.act5: [
CinematicStep(
text: '=== ACT V: ENDGAME ===',
durationMs: 3000,
),
CinematicStep(
text: 'The Glitch God reveals its true form.',
asciiArt: _asciiGlitchGod,
durationMs: 4000,
),
CinematicStep(
text: 'Reality itself begins to corrupt.',
durationMs: 3500,
),
CinematicStep(
text: 'All hope rests upon your shoulders.',
durationMs: 3000,
),
CinematicStep(
text: 'The final battle for the Codebase begins!',
durationMs: 3500,
),
],
// 엔딩: 시스템 재부팅, 평화 회복
StoryAct.ending: [
CinematicStep(
text: '=== THE END ===',
durationMs: 3000,
),
CinematicStep(
text: 'The Glitch God falls. The corruption fades.',
asciiArt: _asciiVictory,
durationMs: 4000,
),
CinematicStep(
text: 'System Reboot initiated...',
durationMs: 3000,
),
CinematicStep(
text: 'Peace returns to the Digital Realm.',
durationMs: 3500,
),
CinematicStep(
text: 'Your legend will be compiled into the eternal logs.',
durationMs: 4000,
),
CinematicStep(
text: 'THE END',
asciiArt: _asciiTheEnd,
durationMs: 5000,
),
CinematicStep(
text: '...or is it?',
durationMs: 3000,
),
],
};
// ============================================================================
// ASCII Art 상수 (ASCII Art Constants)
// ============================================================================
const _asciiVoid = '''
. . .
. . . . .
. . . . . .
. . . . .
. . .
''';
const _asciiLight = '''
\\|/
-- * --
/|\\
''';
const _asciiGlitch = '''
####!!####
# GLITCH #
####!!####
''';
const _asciiBugNest = '''
/\\ /\\
<( o.o )>
> ^^ <
''';
const _asciiDragon = '''
/\\___/\\
( O O )
\\ ^ /
\\~~~/ ~
''';
const _asciiNetwork = '''
[*]--[*]--[*]
| | |
[*]--[!]--[*]
| | |
[*]--[*]--[*]
''';
const _asciiHydra = '''
/\\ /\\ /\\
( O O O )
\\ \\|/ /
\\|||/
''';
const _asciiNullKingdom = '''
+--NULL--+
| ???? |
| VOID |
+--------+
''';
const _asciiTitan = '''
[####]
/| |\\
| | | |
/ |__| \\
''';
const _asciiCitadel = '''
/\\
/ \\
|GLITCH|
|======|
| |
''';
const _asciiArchon = '''
^^^^^
(|O O|)
\\===/
|X|
''';
const _asciiGlitchGod = '''
##########
# G L I #
# T C H #
# GOD #
##########
''';
const _asciiVictory = '''
\\O/
|
/ \\
VICTORY!
''';
const _asciiTheEnd = '''
+-----------+
| THE END |
+-----------+
''';
/// Act별 보스 몬스터 이름 (Boss Monster Names per Act)
const Map<StoryAct, String> actBossNames = {
StoryAct.act1: 'BOSS: Stack Overflow Dragon',
StoryAct.act2: 'BOSS: Heap Corruption Hydra',
StoryAct.act3: 'BOSS: Kernel Panic Titan',
StoryAct.act4: 'BOSS: Zero Day Leviathan',
StoryAct.act5: 'BOSS: The Primordial Glitch',
};
/// Act별 시작 퀘스트 (Starting Quest per Act)
const Map<StoryAct, String> actStartingQuests = {
StoryAct.prologue: 'Exterminate the Bug Infestation',
StoryAct.act1: 'Purge the Bug Nest',
StoryAct.act2: 'Cleanse the Corrupted Network',
StoryAct.act3: 'Pass the Trials of the Ancient Compiler',
StoryAct.act4: "Infiltrate the Glitch God's Citadel",
StoryAct.act5: 'Defeat the Glitch God',
};
/// Act 제목 (Act Titles)
const Map<StoryAct, String> actTitles = {
StoryAct.prologue: 'Prologue',
StoryAct.act1: 'Act I: Awakening',
StoryAct.act2: 'Act II: Growth',
StoryAct.act3: 'Act III: Trials',
StoryAct.act4: 'Act IV: Confrontation',
StoryAct.act5: 'Act V: Endgame',
StoryAct.ending: 'The End',
};

View File

@@ -93,6 +93,9 @@
"encumbrance": "Encumbrance",
"@encumbrance": { "description": "Encumbrance section title" },
"combatLog": "Combat Log",
"@combatLog": { "description": "Combat log panel title" },
"plotDevelopment": "Plot Development",
"@plotDevelopment": { "description": "Plot development panel title" },

View File

@@ -30,6 +30,7 @@
"equipment": "Equipment",
"inventory": "Inventory",
"encumbrance": "Encumbrance",
"combatLog": "戦闘ログ",
"plotDevelopment": "Plot Development",
"quests": "Quests",
"traitName": "Name",

View File

@@ -30,6 +30,7 @@
"equipment": "장비",
"inventory": "인벤토리",
"encumbrance": "적재량",
"combatLog": "전투 로그",
"plotDevelopment": "스토리 진행",
"quests": "퀘스트",
"traitName": "이름",

View File

@@ -275,6 +275,12 @@ abstract class L10n {
/// **'Encumbrance'**
String get encumbrance;
/// Combat log panel title
///
/// In en, this message translates to:
/// **'Combat Log'**
String get combatLog;
/// Plot development panel title
///
/// In en, this message translates to:

View File

@@ -97,6 +97,9 @@ class L10nEn extends L10n {
@override
String get encumbrance => 'Encumbrance';
@override
String get combatLog => 'Combat Log';
@override
String get plotDevelopment => 'Plot Development';

View File

@@ -97,6 +97,9 @@ class L10nJa extends L10n {
@override
String get encumbrance => 'Encumbrance';
@override
String get combatLog => '戦闘ログ';
@override
String get plotDevelopment => 'Plot Development';

View File

@@ -97,6 +97,9 @@ class L10nKo extends L10n {
@override
String get encumbrance => '적재량';
@override
String get combatLog => '전투 로그';
@override
String get plotDevelopment => '스토리 진행';

View File

@@ -97,6 +97,9 @@ class L10nZh extends L10n {
@override
String get encumbrance => 'Encumbrance';
@override
String get combatLog => '战斗日志';
@override
String get plotDevelopment => 'Plot Development';

View File

@@ -30,6 +30,7 @@
"equipment": "Equipment",
"inventory": "Inventory",
"encumbrance": "Encumbrance",
"combatLog": "战斗日志",
"plotDevelopment": "Plot Development",
"quests": "Quests",
"traitName": "Name",

View File

@@ -13,6 +13,7 @@ import 'package:askiineverdie/src/features/front/front_screen.dart';
import 'package:askiineverdie/src/features/front/save_picker_dialog.dart';
import 'package:askiineverdie/src/features/game/game_play_screen.dart';
import 'package:askiineverdie/src/features/game/game_session_controller.dart';
import 'package:askiineverdie/src/features/hall_of_fame/hall_of_fame_screen.dart';
import 'package:askiineverdie/src/features/new_character/new_character_screen.dart';
class AskiiNeverDieApp extends StatefulWidget {
@@ -69,6 +70,7 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
home: FrontScreen(
onNewCharacter: _navigateToNewCharacter,
onLoadSave: _loadSave,
onHallOfFame: _navigateToHallOfFame,
),
);
}
@@ -152,4 +154,13 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
),
);
}
/// Phase 10: 명예의 전당 화면으로 이동
void _navigateToHallOfFame(BuildContext context) {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (context) => const HallOfFameScreen(),
),
);
}
}

View File

@@ -67,116 +67,133 @@ AsciiThemeColors getThemeColors(AsciiColorTheme theme, Brightness brightness) {
};
}
/// 몬스터 카테고리
/// 몬스터 카테고리 (ASCII NEVER DIE 테마)
enum MonsterCategory {
/// 기본 (고양이 모양)
beast,
/// 기본 버그 (Syntax Error, Type Mismatch 등)
bug,
/// 곤충/벌레류
insect,
/// 멀웨어 (Virus, Worm, Trojan 등)
malware,
/// 인간형 (고블린, 오크 등)
humanoid,
/// 네트워크 위협 (Flood, DDoS, Injection 등)
network,
/// 언데드 (스켈레톤, 좀비)
undead,
/// 시스템 위협 (Kernel, Memory, Buffer 등)
system,
/// 드래곤/비행 생물
dragon,
/// 암호화/보안 (Encryption, Hash, Zero-Day 등)
crypto,
/// 슬라임/젤리
slime,
/// AI/ML 위협 (Neural Network, Machine Learning 등)
ai,
/// 악마/마법 생물
demon,
/// 보스 몬스터
boss,
}
/// 몬스터 이름으로 카테고리 결정
/// 몬스터 이름으로 카테고리 결정 (ASCII NEVER DIE 테마)
MonsterCategory getMonsterCategory(String? monsterBaseName) {
if (monsterBaseName == null || monsterBaseName.isEmpty) {
return MonsterCategory.beast;
return MonsterCategory.bug;
}
final name = monsterBaseName.toLowerCase();
// 곤충/벌레류
if (name.contains('ant') ||
name.contains('centipede') ||
name.contains('spider') ||
name.contains('beetle') ||
name.contains('crawler') ||
name.contains('crayfish') ||
name.contains('anhkheg')) {
return MonsterCategory.insect;
// 보스 몬스터
if (name.startsWith('boss:') ||
name.contains('dragon') ||
name.contains('hydra') ||
name.contains('titan') ||
name.contains('leviathan') ||
name.contains('colossus') ||
name.contains('emperor') ||
name.contains('singularity') ||
name.contains('primordial') ||
name.contains('glitch god')) {
return MonsterCategory.boss;
}
// 인간형
if (name.contains('goblin') ||
name.contains('orc') ||
name.contains('troll') ||
name.contains('ogre') ||
name.contains('giant') ||
name.contains('scout') ||
name.contains('bugbear') ||
name.contains('gnoll') ||
name.contains('kobold') ||
name.contains('hobgoblin')) {
return MonsterCategory.humanoid;
// AI/ML 위협
if (name.contains('neural') ||
name.contains('ai ') ||
name.contains('deep') ||
name.contains('model') ||
name.contains('adversarial') ||
name.contains('training') ||
name.contains('federated') ||
name.contains('prompt') ||
name.contains('hallucination')) {
return MonsterCategory.ai;
}
// 언데드
if (name.contains('skeleton') ||
name.contains('zombie') ||
name.contains('ghoul') ||
name.contains('ghost') ||
name.contains('wight') ||
name.contains('wraith') ||
name.contains('vampire') ||
name.contains('lich') ||
name.contains('mummy')) {
return MonsterCategory.undead;
// 암호화/보안 위협
if (name.contains('quantum') ||
name.contains('zero day') ||
name.contains('zero-day') ||
name.contains('crypto') ||
name.contains('hash') ||
name.contains('blockchain') ||
name.contains('homomorphic') ||
name.contains('zero knowledge') ||
name.contains('smart contract')) {
return MonsterCategory.crypto;
}
// 드래곤/비행
if (name.contains('dragon') ||
name.contains('wyvern') ||
name.contains('cockatrice') ||
name.contains('griffin') ||
name.contains('roc') ||
name.contains('harpy') ||
name.contains('couatl')) {
return MonsterCategory.dragon;
// 네트워크 위협
if (name.contains('flood') ||
name.contains('ddos') ||
name.contains('dos') ||
name.contains('injection') ||
name.contains('sql') ||
name.contains('xss') ||
name.contains('csrf') ||
name.contains('amplification') ||
name.contains('tunnel') ||
name.contains('shell') ||
name.contains('backdoor') ||
name.contains('c2') ||
name.contains('beacon')) {
return MonsterCategory.network;
}
// 슬라임/젤리
if (name.contains('slime') ||
name.contains('pudding') ||
name.contains('ooze') ||
name.contains('jelly') ||
name.contains('boogie') ||
name.contains('blob') ||
name.contains('jubilex')) {
return MonsterCategory.slime;
// 시스템 위협
if (name.contains('kernel') ||
name.contains('memory') ||
name.contains('buffer') ||
name.contains('stack') ||
name.contains('heap') ||
name.contains('overflow') ||
name.contains('corruption') ||
name.contains('segfault') ||
name.contains('panic') ||
name.contains('rootkit') ||
name.contains('firmware') ||
name.contains('bootkit')) {
return MonsterCategory.system;
}
// 악마/마법 생물
if (name.contains('demon') ||
name.contains('devil') ||
name.contains('succubus') ||
name.contains('beholder') ||
name.contains('demogorgon') ||
name.contains('orcus') ||
name.contains('vrock') ||
name.contains('hezrou') ||
name.contains('glabrezu')) {
return MonsterCategory.demon;
// 멀웨어
if (name.contains('virus') ||
name.contains('worm') ||
name.contains('trojan') ||
name.contains('ransomware') ||
name.contains('malware') ||
name.contains('botnet') ||
name.contains('cryptominer') ||
name.contains('keylogger') ||
name.contains('spyware') ||
name.contains('dropper') ||
name.contains('loader') ||
name.contains('payload')) {
return MonsterCategory.malware;
}
return MonsterCategory.beast;
// 기본: 버그
return MonsterCategory.bug;
}
/// 기본 전투 애니메이션 (beast - 고양이 모양, 심플 3줄)
const battleAnimationBeast = AsciiAnimationData(
/// 버그 전투 애니메이션 (기본 버그 모양)
const battleAnimationBug = AsciiAnimationData(
frames: [
// 프레임 1: 대치
'''
@@ -312,8 +329,8 @@ const walkingAnimation = AsciiAnimationData(
frameIntervalMs: 180,
);
/// 곤충 전투 애니메이션 (심플 3줄)
const battleAnimationInsect = AsciiAnimationData(
/// 멀웨어 전투 애니메이션 (바이러스 모양)
const battleAnimationMalware = AsciiAnimationData(
frames: [
// 프레임 1: 대치
'''
@@ -344,8 +361,8 @@ const battleAnimationInsect = AsciiAnimationData(
frameIntervalMs: 220,
);
/// 인간형 전투 애니메이션 (심플 3줄)
const battleAnimationHumanoid = AsciiAnimationData(
/// 네트워크 전투 애니메이션 (패킷 모양)
const battleAnimationNetwork = AsciiAnimationData(
frames: [
// 프레임 1: 대치
'''
@@ -376,8 +393,8 @@ const battleAnimationHumanoid = AsciiAnimationData(
frameIntervalMs: 220,
);
/// 언데드 전투 애니메이션 (심플 3줄)
const battleAnimationUndead = AsciiAnimationData(
/// 시스템 전투 애니메이션 (커널 모양)
const battleAnimationSystem = AsciiAnimationData(
frames: [
// 프레임 1: 대치
'''
@@ -408,8 +425,8 @@ const battleAnimationUndead = AsciiAnimationData(
frameIntervalMs: 250,
);
/// 드래곤 전투 애니메이션 (심플 3줄)
const battleAnimationDragon = AsciiAnimationData(
/// 암호화 전투 애니메이션 (자물쇠 모양)
const battleAnimationCrypto = AsciiAnimationData(
frames: [
// 프레임 1: 대치
'''
@@ -440,8 +457,8 @@ const battleAnimationDragon = AsciiAnimationData(
frameIntervalMs: 200,
);
/// 슬라임 전투 애니메이션 (심플 3줄)
const battleAnimationSlime = AsciiAnimationData(
/// AI 전투 애니메이션 (뉴럴넷 모양)
const battleAnimationAI = AsciiAnimationData(
frames: [
// 프레임 1: 대치
'''
@@ -472,8 +489,8 @@ const battleAnimationSlime = AsciiAnimationData(
frameIntervalMs: 280,
);
/// 악마 전투 애니메이션 (심플 3줄)
const battleAnimationDemon = AsciiAnimationData(
/// 보스 전투 애니메이션 (드래곤/보스 모양)
const battleAnimationBoss = AsciiAnimationData(
frames: [
// 프레임 1: 대치
'''
@@ -507,13 +524,13 @@ const battleAnimationDemon = AsciiAnimationData(
/// 몬스터 카테고리별 전투 애니메이션 반환
AsciiAnimationData getBattleAnimation(MonsterCategory category) {
return switch (category) {
MonsterCategory.beast => battleAnimationBeast,
MonsterCategory.insect => battleAnimationInsect,
MonsterCategory.humanoid => battleAnimationHumanoid,
MonsterCategory.undead => battleAnimationUndead,
MonsterCategory.dragon => battleAnimationDragon,
MonsterCategory.slime => battleAnimationSlime,
MonsterCategory.demon => battleAnimationDemon,
MonsterCategory.bug => battleAnimationBug,
MonsterCategory.malware => battleAnimationMalware,
MonsterCategory.network => battleAnimationNetwork,
MonsterCategory.system => battleAnimationSystem,
MonsterCategory.crypto => battleAnimationCrypto,
MonsterCategory.ai => battleAnimationAI,
MonsterCategory.boss => battleAnimationBoss,
};
}
@@ -676,10 +693,10 @@ const actCompleteAnimation = AsciiAnimationData(
frameIntervalMs: 400,
);
/// 타입별 애니메이션 데이터 반환 (기본 전투는 beast)
/// 타입별 애니메이션 데이터 반환 (기본 전투는 bug)
AsciiAnimationData getAnimationData(AsciiAnimationType type) {
return switch (type) {
AsciiAnimationType.battle => battleAnimationBeast,
AsciiAnimationType.battle => battleAnimationBug,
AsciiAnimationType.town => townAnimation,
AsciiAnimationType.walking => walkingAnimation,
AsciiAnimationType.levelUp => levelUpAnimation,

View File

@@ -93,22 +93,29 @@ class BattleComposer {
_overlaySpriteWithSpaces(canvas, normalizedChar, charX, charY);
// 4. 몬스터 프레임 (정규화하여 오른쪽 정렬)
// idle 프레임 기준 너비로 정렬하여 hit/alert 시 위치 이동 방지
final monsterRefWidth = _getMonsterReferenceWidth(monsterCategory, monsterSize);
final monsterFrames =
_getAnimatedMonsterFrames(monsterCategory, monsterSize, phase);
final monsterFrame = monsterFrames[subFrame % monsterFrames.length];
final normalizedMonster = _normalizeSpriteRight(monsterFrame, monsterWidth);
final normalizedMonster = _normalizeSpriteRight(
monsterFrame,
monsterWidth,
referenceWidth: monsterRefWidth,
);
final monsterX = frameWidth - monsterWidth;
// 바닥 레이어(Y=7) 위에 서있도록 -1
final monsterY = frameHeight - normalizedMonster.length - 1;
_overlaySpriteWithSpaces(canvas, normalizedMonster, monsterX, monsterY);
// 몬스터는 경계 내 완전 렌더링 (내부 공백에 배경이 비치지 않도록)
_overlaySpriteWithBounds(canvas, normalizedMonster, monsterX, monsterY);
// 5. 멀티라인 이펙트 오버레이 (공격/히트 페이즈)
if (phase == BattlePhase.attack || phase == BattlePhase.hit) {
final effect = getWeaponEffect(weaponCategory);
final effectLines = _getEffectLines(effect, phase, subFrame);
if (effectLines.isNotEmpty) {
// 이펙트 Y 위치: 캐릭터 높이 (2번째 줄, 몸통) 기준
final effectY = charY + 1;
// 이펙트 Y 위치: 캐릭터 머리 높이 (1번째 줄) 기준 - 수정됨
final effectY = charY;
// 이펙트 X 위치: 캐릭터 오른쪽에 붙여서 표시
final effectX = charX + 6;
for (var i = 0; i < effectLines.length; i++) {
@@ -129,16 +136,64 @@ class BattleComposer {
return sprite.map((line) => line.padRight(width).substring(0, width)).toList();
}
/// 스프라이트를 지정 폭으로 정규화 (오른쪽 정렬)
List<String> _normalizeSpriteRight(List<String> sprite, int width) {
return sprite.map((line) {
final trimmed = line.trimRight();
if (trimmed.length >= width) return trimmed.substring(0, width);
return trimmed.padLeft(width);
/// 스프라이트를 지정 폭으로 정규화 (오른쪽 정렬, 전체 스프라이트 기준)
///
/// 모든 줄을 동일한 기준점에서 오른쪽 정렬하여
/// 머리와 몸통이 분리되지 않도록 함
///
/// [referenceWidth] 지정 시 해당 너비를 기준으로 정렬 (idle/hit 프레임 일관성용)
List<String> _normalizeSpriteRight(
List<String> sprite,
int width, {
int? referenceWidth,
}) {
// 1. 각 줄의 실제 너비(오른쪽 공백 제외) 계산
final trimmedLines = sprite.map((line) => line.trimRight()).toList();
// 2. 기준 너비 결정 (referenceWidth 있으면 사용, 없으면 현재 스프라이트 기준)
int maxLineWidth;
if (referenceWidth != null) {
maxLineWidth = referenceWidth;
} else {
maxLineWidth = 0;
for (final line in trimmedLines) {
if (line.length > maxLineWidth) {
maxLineWidth = line.length;
}
}
}
// 3. 전체 스프라이트를 오른쪽 정렬 (width 기준)
// 모든 줄에 동일한 왼쪽 패딩 적용
final leftPadding = width - maxLineWidth;
final paddingStr = leftPadding > 0 ? ' ' * leftPadding : '';
return trimmedLines.map((line) {
// 각 줄을 왼쪽에 공통 패딩 추가 후 width로 자르기
final paddedLine = paddingStr + line;
if (paddedLine.length > width) {
return paddedLine.substring(paddedLine.length - width);
}
return paddedLine.padRight(width);
}).toList();
}
/// 스프라이트를 캔버스에 오버레이 (공백도 덮어쓰기 - Z-order용)
/// 몬스터 스프라이트의 기준 너비 계산 (idle 프레임 기준)
int _getMonsterReferenceWidth(MonsterCategory category, MonsterSize size) {
final idleFrames = _getMonsterIdleFrames(category, size);
int maxWidth = 0;
for (final frame in idleFrames) {
for (final line in frame) {
final trimmedLength = line.trimRight().length;
if (trimmedLength > maxWidth) {
maxWidth = trimmedLength;
}
}
}
return maxWidth;
}
/// 스프라이트를 캔버스에 오버레이 (공백은 투명 처리)
void _overlaySpriteWithSpaces(
List<List<String>> canvas,
List<String> sprite,
@@ -163,6 +218,43 @@ class BattleComposer {
}
}
/// 스프라이트를 캔버스에 오버레이 (라인별 경계 내 완전 렌더링)
///
/// 각 라인에서 첫 번째와 마지막 비공백 문자 사이의 모든 문자를 그림.
/// 내부 공백도 그려져서 스크롤링 배경이 비치지 않음.
void _overlaySpriteWithBounds(
List<List<String>> canvas,
List<String> sprite,
int startX,
int startY,
) {
for (var i = 0; i < sprite.length; i++) {
final y = startY + i;
if (y < 0 || y >= frameHeight) continue;
final line = sprite[i];
// 각 라인에서 첫/마지막 비공백 문자 위치 찾기
int firstNonSpace = -1;
int lastNonSpace = -1;
for (var j = 0; j < line.length; j++) {
if (line[j] != ' ') {
if (firstNonSpace == -1) firstNonSpace = j;
lastNonSpace = j;
}
}
if (firstNonSpace == -1) continue; // 빈 라인
// 경계 내 모든 문자 그리기 (공백 포함)
for (var j = firstNonSpace; j <= lastNonSpace; j++) {
final x = startX + j;
if (x < 0 || x >= frameWidth) continue;
canvas[y][x] = line[j];
}
}
}
/// 배경 레이어를 캔버스에 그리기
void _drawBackgroundLayer(
List<List<String>> canvas,
@@ -338,31 +430,31 @@ List<List<String>> _getMonsterAlertFrames(MonsterCategory category, MonsterSize
List<List<String>> _tinyIdleFrames(MonsterCategory category) {
return switch (category) {
MonsterCategory.beast => [
MonsterCategory.bug => [
[r'*', r'/\'],
[r'o', r'\/'],
],
MonsterCategory.insect => [
MonsterCategory.malware => [
[r'><', r'\/'],
[r'<>', r'/\'],
],
MonsterCategory.humanoid => [
MonsterCategory.network => [
[r'o', r'|'],
[r'O', r'|'],
],
MonsterCategory.undead => [
MonsterCategory.system => [
[r'+', r'|'],
[r'x', r'|'],
],
MonsterCategory.dragon => [
MonsterCategory.crypto => [
[r'~<', r'>>'],
[r'<~', r'<<'],
],
MonsterCategory.slime => [
MonsterCategory.ai => [
[r'()', r''],
[r'{}', r''],
],
MonsterCategory.demon => [
MonsterCategory.boss => [
[r'^v', r'\/'],
[r'v^', r'/\'],
],
@@ -378,31 +470,31 @@ List<List<String>> _tinyHitFrames(MonsterCategory category) {
List<List<String>> _tinyAlertFrames(MonsterCategory category) {
return switch (category) {
MonsterCategory.beast => [
MonsterCategory.bug => [
[r'!!', r'/\'],
[r'OO', r'><'],
],
MonsterCategory.insect => [
MonsterCategory.malware => [
[r'!!', r'\/'],
[r'@@', r'/\'],
],
MonsterCategory.humanoid => [
MonsterCategory.network => [
[r'O!', r'|'],
[r'!O', r'X'],
],
MonsterCategory.undead => [
MonsterCategory.system => [
[r'!!', r'X'],
[r'@@', r'|'],
],
MonsterCategory.dragon => [
MonsterCategory.crypto => [
[r'!<', r'>>'],
[r'>!', r'<<'],
],
MonsterCategory.slime => [
MonsterCategory.ai => [
[r'(!)', r''],
[r'{!}', r''],
],
MonsterCategory.demon => [
MonsterCategory.boss => [
[r'^!', r'><'],
[r'!^', r'<>'],
],
@@ -415,31 +507,31 @@ List<List<String>> _tinyAlertFrames(MonsterCategory category) {
List<List<String>> _smallIdleFrames(MonsterCategory category) {
return switch (category) {
MonsterCategory.beast => [
MonsterCategory.bug => [
[r' /\_/\', r'( o.o )', r' > ^ <', r' /| |\'],
[r' /\_/\', r'( o o )', r' > v <', r' \| |/'],
],
MonsterCategory.insect => [
MonsterCategory.malware => [
[r' /\/\', r' (O O)', r' / \', r' \/ \/'],
[r' \/\/\', r' (O O)', r' \ /', r' /\ /\'],
],
MonsterCategory.humanoid => [
MonsterCategory.network => [
[r' O', r' /|\', r' / \', r' _| |_'],
[r' O', r' \|/', r' | |', r' _/ \_'],
],
MonsterCategory.undead => [
MonsterCategory.system => [
[r' _+_', r' (x_x)', r' /|\', r' _/ \_'],
[r' _+_', r' (X_X)', r' \|/', r' _| |_'],
],
MonsterCategory.dragon => [
MonsterCategory.crypto => [
[r' __', r' <(oo)~', r' / \', r' <_ _>'],
[r' __', r' (oo)>', r' \ /', r' <_ _>'],
],
MonsterCategory.slime => [
MonsterCategory.ai => [
[r' ___', r' ( )', r' ( )', r' \_/'],
[r' _', r' / \', r' { }', r' \_/'],
],
MonsterCategory.demon => [
MonsterCategory.boss => [
[r' ^w^', r' (|o|)', r' /|\', r' V V'],
[r' ^W^', r' (|O|)', r' \|/', r' v v'],
],
@@ -455,31 +547,31 @@ List<List<String>> _smallHitFrames(MonsterCategory category) {
List<List<String>> _smallAlertFrames(MonsterCategory category) {
return switch (category) {
MonsterCategory.beast => [
MonsterCategory.bug => [
[r' /\_/\', r'( O!O )', r' > ! <', r' /| |\'],
[r' /\_/\', r'( !O! )', r' > ! <', r' \| |/'],
],
MonsterCategory.insect => [
MonsterCategory.malware => [
[r' /\/\', r' (! !)', r' / \', r' \/ \/'],
[r' \/\/\', r' (! !)', r' \ /', r' /\ /\'],
],
MonsterCategory.humanoid => [
MonsterCategory.network => [
[r' O!', r' /|\', r' / \', r' _| |_'],
[r' !O', r' \|/', r' | |', r' _/ \_'],
],
MonsterCategory.undead => [
MonsterCategory.system => [
[r' _!_', r' (!_!)', r' /|\', r' _/ \_'],
[r' _!_', r' (!_!)', r' \|/', r' _| |_'],
],
MonsterCategory.dragon => [
MonsterCategory.crypto => [
[r' __', r' <(!!)~', r' / \', r' <_ _>'],
[r' __', r' (!!)>', r' \ /', r' <_ _>'],
],
MonsterCategory.slime => [
MonsterCategory.ai => [
[r' ___', r' ( ! )', r' ( ! )', r' \_/'],
[r' _', r' /!\', r' { ! }', r' \_/'],
],
MonsterCategory.demon => [
MonsterCategory.boss => [
[r' ^!^', r' (|!|)', r' /|\', r' V V'],
[r' ^!^', r' (|!|)', r' \|/', r' v v'],
],
@@ -492,31 +584,31 @@ List<List<String>> _smallAlertFrames(MonsterCategory category) {
List<List<String>> _mediumIdleFrames(MonsterCategory category) {
return switch (category) {
MonsterCategory.beast => [
MonsterCategory.bug => [
[r' /\_/\', r' ( O.O )', r' > ^ <', r' /| |\', r' | | | |', r'_|_| |_|_'],
[r' /\_/\', r' ( O O )', r' > v <', r' \| |/', r' | | | |', r'_|_| |_|_'],
],
MonsterCategory.insect => [
MonsterCategory.malware => [
[r' /\/\', r' /O O\', r' \ /', r' / \', r' \/ \/', r' _/ \_'],
[r' \/\/\', r' \O O/', r' / \', r' \ /', r' /\ /\', r' _\ /_'],
],
MonsterCategory.humanoid => [
MonsterCategory.network => [
[r' O', r' /|\', r' / \', r' | |', r' | |', r' _| |_'],
[r' O', r' \|/', r' | |', r' | |', r' | |', r' _/ \_'],
],
MonsterCategory.undead => [
MonsterCategory.system => [
[r' _+_', r' (X_X)', r' /|\', r' / | \', r' | | |', r'_/ | \_'],
[r' _x_', r' (x_x)', r' \|/', r' \ | /', r' | | |', r'_\ | /_'],
],
MonsterCategory.dragon => [
MonsterCategory.crypto => [
[r' __', r' <(OO)~', r' / \', r' / \', r' | |', r'<__ __>'],
[r' __', r' (OO)>', r' \ /', r' \ /', r' | |', r'<__ __>'],
],
MonsterCategory.slime => [
MonsterCategory.ai => [
[r' ____', r' / \', r' ( )', r' ( )', r' \ /', r' \__/'],
[r' __', r' / \', r' / \', r' { }', r' \ /', r' \__/'],
],
MonsterCategory.demon => [
MonsterCategory.boss => [
[r' ^W^', r' (|O|)', r' /|\', r' / | \', r' V V', r' _/ \_'],
[r' ^w^', r' (|o|)', r' \|/', r' \ | /', r' v v', r' _\ /_'],
],
@@ -532,31 +624,31 @@ List<List<String>> _mediumHitFrames(MonsterCategory category) {
List<List<String>> _mediumAlertFrames(MonsterCategory category) {
return switch (category) {
MonsterCategory.beast => [
MonsterCategory.bug => [
[r' /\_/\', r' ( O!O )', r' > ! <', r' /| |\', r' | | | |', r'_|_| |_|_'],
[r' /\_/\', r' ( !O! )', r' > ! <', r' \| |/', r' | | | |', r'_|_| |_|_'],
],
MonsterCategory.insect => [
MonsterCategory.malware => [
[r' /\/\', r' /! !\', r' \ /', r' / \', r' \/ \/', r' _/ \_'],
[r' \/\/\', r' \! !/', r' / \', r' \ /', r' /\ /\', r' _\ /_'],
],
MonsterCategory.humanoid => [
MonsterCategory.network => [
[r' O!', r' /|\', r' / \', r' | |', r' | |', r' _| |_'],
[r' !O', r' \|/', r' | |', r' | |', r' | |', r' _/ \_'],
],
MonsterCategory.undead => [
MonsterCategory.system => [
[r' _!_', r' (!_!)', r' /|\', r' / | \', r' | | |', r'_/ | \_'],
[r' _!_', r' (!_!)', r' \|/', r' \ | /', r' | | |', r'_\ | /_'],
],
MonsterCategory.dragon => [
MonsterCategory.crypto => [
[r' __', r' <(!!)~', r' / \', r' / \', r' | |', r'<__ __>'],
[r' __', r' (!!)>', r' \ /', r' \ /', r' | |', r'<__ __>'],
],
MonsterCategory.slime => [
MonsterCategory.ai => [
[r' ____', r' / ! \', r' ( ! )', r' ( ! )', r' \ /', r' \__/'],
[r' __', r' / !\', r' / ! \', r' { ! }', r' \ /', r' \__/'],
],
MonsterCategory.demon => [
MonsterCategory.boss => [
[r' ^!^', r' (|!|)', r' /|\', r' / | \', r' V V', r' _/ \_'],
[r' ^!^', r' (|!|)', r' \|/', r' \ | /', r' v v', r' _\ /_'],
],
@@ -569,31 +661,31 @@ List<List<String>> _mediumAlertFrames(MonsterCategory category) {
List<List<String>> _largeIdleFrames(MonsterCategory category) {
return switch (category) {
MonsterCategory.beast => [
MonsterCategory.bug => [
[r' /\__/\', r' ( O O )', r' > ^^ <', r' /| |\', r' | | | |', r' | | | |', r'_| | | |_', r'|__|____|__|'],
[r' /\__/\', r' ( O O )', r' > vv <', r' \| |/', r' | | | |', r' | | | |', r'_| | | |_', r'|__|____|__|'],
],
MonsterCategory.insect => [
MonsterCategory.malware => [
[r' /\/\/\', r' /O O\', r' \ /', r' / \', r' \/ \/', r' | \ / |', r' _| \/ |_', r'|___________|'],
[r' \/\/\/\', r' \O O/', r' / \', r' \ /', r' /\ /\', r' | / \ |', r' _| /\ |_', r'|___________|'],
],
MonsterCategory.humanoid => [
MonsterCategory.network => [
[r' O', r' /|\', r' / \', r' | |', r' | |', r' | |', r' _| |_', r'|_________|'],
[r' O', r' \|/', r' | |', r' | |', r' | |', r' | |', r' _/ \_', r'|_________|'],
],
MonsterCategory.undead => [
MonsterCategory.system => [
[r' _/+\_', r' (X___X)', r' /|||\', r' / ||| \', r' | ||| |', r' | / \ |', r' _|/ \|_', r'|_/ \_|'],
[r' _\+/_', r' (x___x)', r' \|||/', r' \ ||| /', r' | ||| |', r' | \ / |', r' _|\ /|_', r'|_\ /_|'],
],
MonsterCategory.dragon => [
MonsterCategory.crypto => [
[r' ___', r' <<(O O)~~', r' / || \', r' / || \', r' | || |', r' | || |', r' _| || |_', r'<___________|>'],
[r' ___', r' (O O)>>', r' \ || /', r' \ || /', r' | || |', r' | || |', r' _| || |_', r'<___________|>'],
],
MonsterCategory.slime => [
MonsterCategory.ai => [
[r' _____', r' / \', r' / \', r' ( )', r' ( )', r' \ /', r' \_____/', r' \___/'],
[r' ___', r' / \', r' / \', r' { }', r' { }', r' \ /', r' \___/', r' \_/'],
],
MonsterCategory.demon => [
MonsterCategory.boss => [
[r' ^W^', r' /|O|\', r' /|\', r' / | \', r' | | |', r' V | V', r' _/ | \_', r'|____|____|'],
[r' ^w^', r' \|o|/', r' \|/', r' \ | /', r' | | |', r' v | v', r' _\ | /_', r'|____|____|'],
],
@@ -609,31 +701,31 @@ List<List<String>> _largeHitFrames(MonsterCategory category) {
List<List<String>> _largeAlertFrames(MonsterCategory category) {
return switch (category) {
MonsterCategory.beast => [
MonsterCategory.bug => [
[r' /\__/\', r' ( O!!O )', r' > !! <', r' /| |\', r' | | | |', r' | | | |', r'_| | | |_', r'|__|____|__|'],
[r' /\__/\', r' ( !!O! )', r' > !! <', r' \| |/', r' | | | |', r' | | | |', r'_| | | |_', r'|__|____|__|'],
],
MonsterCategory.insect => [
MonsterCategory.malware => [
[r' /\/\/\', r' /! !\', r' \ /', r' / \', r' \/ \/', r' | \ / |', r' _| \/ |_', r'|___________|'],
[r' \/\/\/\', r' \! !/', r' / \', r' \ /', r' /\ /\', r' | / \ |', r' _| /\ |_', r'|___________|'],
],
MonsterCategory.humanoid => [
MonsterCategory.network => [
[r' O!', r' /|\', r' / \', r' | |', r' | |', r' | |', r' _| |_', r'|_________|'],
[r' !O', r' \|/', r' | |', r' | |', r' | |', r' | |', r' _/ \_', r'|_________|'],
],
MonsterCategory.undead => [
MonsterCategory.system => [
[r' _/!\_', r' (!___!)', r' /|||\', r' / ||| \', r' | ||| |', r' | / \ |', r' _|/ \|_', r'|_/ \_|'],
[r' _\!/_', r' (!___!)', r' \|||/', r' \ ||| /', r' | ||| |', r' | \ / |', r' _|\ /|_', r'|_\ /_|'],
],
MonsterCategory.dragon => [
MonsterCategory.crypto => [
[r' ___', r' <<(! !)~~', r' / || \', r' / || \', r' | || |', r' | || |', r' _| || |_', r'<___________|>'],
[r' ___', r' (! !)>>', r' \ || /', r' \ || /', r' | || |', r' | || |', r' _| || |_', r'<___________|>'],
],
MonsterCategory.slime => [
MonsterCategory.ai => [
[r' _____', r' / ! \', r' / ! \', r' ( ! )', r' ( ! )', r' \ /', r' \_____/', r' \___/'],
[r' ___', r' / ! \', r' / ! \', r' { ! }', r' { ! }', r' \ /', r' \___/', r' \_/'],
],
MonsterCategory.demon => [
MonsterCategory.boss => [
[r' ^!^', r' /|!|\', r' /|\', r' / | \', r' | | |', r' V | V', r' _/ | \_', r'|____|____|'],
[r' ^!^', r' \|!|/', r' \|/', r' \ | /', r' | | |', r' v | v', r' _\ | /_', r'|____|____|'],
],
@@ -646,31 +738,31 @@ List<List<String>> _largeAlertFrames(MonsterCategory category) {
List<List<String>> _hugeIdleFrames(MonsterCategory category) {
return switch (category) {
MonsterCategory.beast => [
MonsterCategory.bug => [
[r' /\____/\', r' ( O O )', r' > ^^^^ <', r' /| |\', r' | | | |', r' | | | |', r' _| | | |_', r'|___|______|___|'],
[r' /\____/\', r' ( O O )', r' > vvvv <', r' \| |/', r' | | | |', r' | | | |', r' _| | | |_', r'|___|______|___|'],
],
MonsterCategory.insect => [
MonsterCategory.malware => [
[r' /\/\/\/\', r' /O O\', r' \ /', r' / \', r' \/ \/', r' | \ / |', r' _| \ / |_', r'|_______________|'],
[r' \/\/\/\/\', r' \O O/', r' / \', r' \ /', r' /\ /\', r' | / \ |', r' _| / \ |_', r'|_______________|'],
],
MonsterCategory.humanoid => [
MonsterCategory.network => [
[r' O', r' _/|\\_', r' / | \\', r' | |', r' | |', r' | |', r' _| |_', r'|___________|'],
[r' O', r' \\_|_/', r' \\|/', r' | |', r' | |', r' | |', r' _/ \\_', r'|___________|'],
],
MonsterCategory.undead => [
MonsterCategory.system => [
[r' _/+\\_', r' (X_____X)', r' /|||||\', r' / ||||| \\', r' | ||||| |', r' | / \\ |', r' _|/ \\|_', r'|_/ \\_|'],
[r' _\\+/_', r' (x_____x)', r' \\|||||/', r' \\ ||||| /', r' | ||||| |', r' | \\ / |', r' _|\\ /|_', r'|_\\ /_|'],
],
MonsterCategory.dragon => [
MonsterCategory.crypto => [
[r' ____', r' <<<(O O)~~~', r' / |||| \\', r' / |||| \\', r' | |||| |', r' | |||| |', r' _| |||| |_', r'<_______________|>'],
[r' ____', r' (O O)>>>', r' \\ |||| /', r' \\ |||| /', r' | |||| |', r' | |||| |', r' _| |||| |_', r'<_______________|>'],
],
MonsterCategory.slime => [
MonsterCategory.ai => [
[r' ______', r' / \\', r' / \\', r' ( )', r' ( )', r' \\ /', r' \\______/', r' \\____/'],
[r' ____', r' / \\', r' / \\', r' { }', r' { }', r' \\ /', r' \\____/', r' \\__/'],
],
MonsterCategory.demon => [
MonsterCategory.boss => [
[r' ^W^', r' /|O|\\ ', r' /|\\', r' / | \\', r' | | |', r' V | V', r' _/ | \\_', r'|_____|_____|'],
[r' ^w^', r' \\|o|/', r' \\|/', r' \\ | /', r' | | |', r' v | v', r' _\\ | /_', r'|_____|_____|'],
],
@@ -686,31 +778,31 @@ List<List<String>> _hugeHitFrames(MonsterCategory category) {
List<List<String>> _hugeAlertFrames(MonsterCategory category) {
return switch (category) {
MonsterCategory.beast => [
MonsterCategory.bug => [
[r' /\____/\', r' ( ! ! )', r' > !!!! <', r' /| |\', r' | | | |', r' | | | |', r' _| | | |_', r'|___|______|___|'],
[r' /\____/\', r' ( ! ! )', r' > !!!! <', r' \| |/', r' | | | |', r' | | | |', r' _| | | |_', r'|___|______|___|'],
],
MonsterCategory.insect => [
MonsterCategory.malware => [
[r' /\/\/\/\', r' /! !\', r' \ /', r' / \', r' \/ \/', r' | \ / |', r' _| \ / |_', r'|_______________|'],
[r' \/\/\/\/\', r' \! !/', r' / \', r' \ /', r' /\ /\', r' | / \ |', r' _| / \ |_', r'|_______________|'],
],
MonsterCategory.humanoid => [
MonsterCategory.network => [
[r' O!', r' _/|\\__', r' / | \\', r' | |', r' | |', r' | |', r' _| |_', r'|___________|'],
[r' !O', r' \\_|_/', r' \\|/', r' | |', r' | |', r' | |', r' _/ \\_', r'|___________|'],
],
MonsterCategory.undead => [
MonsterCategory.system => [
[r' _/!\\__', r' (!_____!)', r' /|||||\', r' / ||||| \\', r' | ||||| |', r' | / \\ |', r' _|/ \\|_', r'|_/ \\_|'],
[r' _\\!/_', r' (!_____!)', r' \\|||||/', r' \\ ||||| /', r' | ||||| |', r' | \\ / |', r' _|\\ /|_', r'|_\\ /_|'],
],
MonsterCategory.dragon => [
MonsterCategory.crypto => [
[r' ____', r' <<<(! !)~~~', r' / |||| \\', r' / |||| \\', r' | |||| |', r' | |||| |', r' _| |||| |_', r'<_______________|>'],
[r' ____', r' (! !)>>>', r' \\ |||| /', r' \\ |||| /', r' | |||| |', r' | |||| |', r' _| |||| |_', r'<_______________|>'],
],
MonsterCategory.slime => [
MonsterCategory.ai => [
[r' ______', r' / ! \\', r' / ! \\', r' ( ! )', r' ( ! )', r' \\ /', r' \\______/', r' \\____/'],
[r' ____', r' / ! \\', r' / ! \\', r' { ! }', r' { ! }', r' \\ /', r' \\____/', r' \\__/'],
],
MonsterCategory.demon => [
MonsterCategory.boss => [
[r' ^!^', r' /|!|\\ ', r' /|\\', r' / | \\', r' | | |', r' V | V', r' _/ | \\_', r'|_____|_____|'],
[r' ^!^', r' \\|!|/', r' \\|/', r' \\ | /', r' | | |', r' v | v', r' _\\ | /_', r'|_____|_____|'],
],

View File

@@ -108,7 +108,8 @@ const _prepareFrames = [
// ============================================================================
// 공격 프레임 (전진 + 휘두르기) - 5프레임, 심플 3줄 스타일
// 구조: [머리, 몸통+팔+무기, 다리]
// 구조: [머리+공격, 몸통+팔, 다리]
// 수정: 공격 이펙트를 머리 줄로 통일 (1칸 위로)
// ============================================================================
const _attackFrames = [
CharacterFrame([
@@ -122,13 +123,13 @@ const _attackFrames = [
r' / \ ',
]),
CharacterFrame([
r' o ',
r' /|-- ',
r' o-- ',
r' /| ',
r' / \ ',
]),
CharacterFrame([
r' o ',
r' /|-=>',
r' o-=>',
r' /| ',
r' / \ ',
]),
CharacterFrame([
@@ -140,22 +141,23 @@ const _attackFrames = [
// ============================================================================
// 히트 프레임 (공격 명중) - 3프레임, 심플 3줄 스타일
// 구조: [머리, 몸통+팔+이펙트, 다리]
// 구조: [머리+이펙트, 몸통+팔, 다리]
// 수정: 히트 이펙트를 머리 줄로 통일 (1칸 위로)
// ============================================================================
const _hitFrames = [
CharacterFrame([
r' o ',
r' /|-* ',
r' o-* ',
r' /| ',
r' / \ ',
]),
CharacterFrame([
r' o ',
r' /|=* ',
r' o=* ',
r' /| ',
r' / \ ',
]),
CharacterFrame([
r' o ',
r' /|~* ',
r' o~* ',
r' /| ',
r' / \ ',
]),
];

View File

@@ -0,0 +1,81 @@
import 'package:flutter/material.dart';
/// ASCII 애니메이션 4색 팔레트 (Phase 7)
///
/// 시각적 명확성을 위해 4가지 색상만 사용한다.
/// - 흰색: 오브젝트 (캐릭터, 몬스터, 아이템)
/// - 시안: 포지티브 이펙트 (힐, 버프, 레벨업, 획득)
/// - 마젠타: 네거티브 이펙트 (데미지, 디버프, 사망, 손실)
/// - 검정: 배경
class AsciiColors {
AsciiColors._();
/// 오브젝트 색상 (캐릭터, 몬스터, 아이템)
static const Color object = Colors.white;
/// 포지티브 이펙트 색상 (힐, 버프, 레벨업, 획득)
static const Color positive = Colors.cyan;
/// 네거티브 이펙트 색상 (데미지, 디버프, 사망, 손실)
static const Color negative = Color(0xFFFF00FF); // 마젠타
/// 배경 색상
static const Color background = Colors.black;
/// 상황에 따른 색상 반환
static Color forContext(AsciiColorContext context) {
return switch (context) {
AsciiColorContext.idle => object,
AsciiColorContext.attack => object,
AsciiColorContext.critical => negative,
AsciiColorContext.heal => positive,
AsciiColorContext.buff => positive,
AsciiColorContext.debuff => negative,
AsciiColorContext.levelUp => positive,
AsciiColorContext.death => negative,
AsciiColorContext.itemGain => positive,
AsciiColorContext.itemLoss => negative,
AsciiColorContext.dodge => object,
AsciiColorContext.block => object,
};
}
}
/// ASCII 애니메이션 색상 컨텍스트
enum AsciiColorContext {
/// 대기 상태
idle,
/// 일반 공격
attack,
/// 크리티컬 히트
critical,
/// 회복
heal,
/// 버프 획득
buff,
/// 디버프 적용
debuff,
/// 레벨업
levelUp,
/// 사망
death,
/// 아이템 획득
itemGain,
/// 아이템 손실
itemLoss,
/// 회피 성공
dodge,
/// 방패 방어
block,
}

View File

@@ -6,6 +6,7 @@ import 'package:askiineverdie/src/core/engine/combat_calculator.dart';
import 'package:askiineverdie/src/core/engine/game_mutations.dart';
import 'package:askiineverdie/src/core/engine/reward_service.dart';
import 'package:askiineverdie/src/core/engine/skill_service.dart';
import 'package:askiineverdie/src/core/model/combat_event.dart';
import 'package:askiineverdie/src/core/model/combat_state.dart';
import 'package:askiineverdie/src/core/model/combat_stats.dart';
import 'package:askiineverdie/src/core/model/equipment_item.dart';
@@ -225,11 +226,19 @@ class ProgressService {
// 킬 태스크 완료 시 전투 결과 반영 및 전리품 획득
if (gain) {
// 전투 결과에 따라 플레이어 HP 업데이트
// 전투 결과에 따라 플레이어 HP 업데이트 + 전투 후 회복
final combat = progress.currentCombat;
if (combat != null && combat.isActive) {
// 전투 중 데미지를 실제 Stats에 반영
final newHp = combat.playerStats.hpCurrent;
// 전투 중 HP
final remainingHp = combat.playerStats.hpCurrent;
final maxHp = combat.playerStats.hpMax;
// 전투 승리 시 HP 회복 (50% + CON/2)
// 아이들 게임 특성상 전투 사이 HP가 회복되어야 지속 플레이 가능
final conBonus = nextState.stats.con ~/ 2;
final healAmount = (maxHp * 0.5).round() + conBonus;
final newHp = (remainingHp + healAmount).clamp(0, maxHp);
nextState = nextState.copyWith(
stats: nextState.stats.copyWith(hpCurrent: newHp),
);
@@ -456,9 +465,17 @@ class ProgressService {
level: level,
);
// 전투용 몬스터 레벨 조정 (밸런스)
// config의 raw 레벨이 플레이어보다 너무 높으면 전투가 불가능
// 플레이어 레벨 ±3 범위로 제한 (최소 1)
final effectiveMonsterLevel = monsterResult.level.clamp(
math.max(1, level - 3),
level + 3,
).toInt();
final monsterCombatStats = MonsterCombatStats.fromLevel(
name: monsterResult.displayName,
level: monsterResult.level,
level: effectiveMonsterLevel,
speedType: MonsterCombatStats.inferSpeedType(monsterResult.baseName),
);
@@ -875,6 +892,10 @@ class ProgressService {
var turnsElapsed = combat.turnsElapsed;
var updatedSkillSystem = skillSystem;
// 새 전투 이벤트 수집
final newEvents = <CombatEvent>[];
final timestamp = updatedSkillSystem.elapsedMs;
// 플레이어 공격 체크
if (playerAccumulator >= playerStats.attackDelayMs) {
// 스킬 자동 선택
@@ -905,6 +926,14 @@ class ProgressService {
monsterStats = skillResult.updatedMonster;
totalDamageDealt += skillResult.result.damage;
updatedSkillSystem = skillResult.updatedSkillSystem;
// 스킬 공격 이벤트 생성
newEvents.add(CombatEvent.playerSkill(
timestamp: timestamp,
skillName: selectedSkill.name,
damage: skillResult.result.damage,
targetName: monsterStats.name,
));
} else if (selectedSkill != null && selectedSkill.isHeal) {
// 회복 스킬 사용
final skillResult = skillService.useHealSkill(
@@ -914,6 +943,13 @@ class ProgressService {
);
playerStats = skillResult.updatedPlayer;
updatedSkillSystem = skillResult.updatedSkillSystem;
// 회복 이벤트 생성
newEvents.add(CombatEvent.playerHeal(
timestamp: timestamp,
healAmount: skillResult.result.healedAmount,
skillName: selectedSkill.name,
));
} else if (selectedSkill != null && selectedSkill.isBuff) {
// 버프 스킬 사용
final skillResult = skillService.useBuffSkill(
@@ -923,6 +959,12 @@ class ProgressService {
);
playerStats = skillResult.updatedPlayer;
updatedSkillSystem = skillResult.updatedSkillSystem;
// 버프 이벤트 생성
newEvents.add(CombatEvent.playerBuff(
timestamp: timestamp,
skillName: selectedSkill.name,
));
} else {
// 일반 공격
final attackResult = calculator.playerAttackMonster(
@@ -931,6 +973,22 @@ class ProgressService {
);
monsterStats = attackResult.updatedDefender;
totalDamageDealt += attackResult.result.damage;
// 일반 공격 이벤트 생성
final result = attackResult.result;
if (result.isEvaded) {
newEvents.add(CombatEvent.monsterEvade(
timestamp: timestamp,
targetName: monsterStats.name,
));
} else {
newEvents.add(CombatEvent.playerAttack(
timestamp: timestamp,
damage: result.damage,
targetName: monsterStats.name,
isCritical: result.isCritical,
));
}
}
playerAccumulator -= playerStats.attackDelayMs;
@@ -946,11 +1004,44 @@ class ProgressService {
playerStats = attackResult.updatedDefender;
totalDamageTaken += attackResult.result.damage;
monsterAccumulator -= monsterStats.attackDelayMs;
// 몬스터 공격 이벤트 생성
final result = attackResult.result;
if (result.isEvaded) {
newEvents.add(CombatEvent.playerEvade(
timestamp: timestamp,
attackerName: monsterStats.name,
));
} else if (result.isBlocked) {
newEvents.add(CombatEvent.playerBlock(
timestamp: timestamp,
reducedDamage: result.damage,
attackerName: monsterStats.name,
));
} else if (result.isParried) {
newEvents.add(CombatEvent.playerParry(
timestamp: timestamp,
reducedDamage: result.damage,
attackerName: monsterStats.name,
));
} else {
newEvents.add(CombatEvent.monsterAttack(
timestamp: timestamp,
damage: result.damage,
attackerName: monsterStats.name,
));
}
}
// 전투 종료 체크
final isActive = playerStats.isAlive && monsterStats.isAlive;
// 기존 이벤트와 합쳐서 최대 10개 유지
final combinedEvents = [...combat.recentEvents, ...newEvents];
final recentEvents = combinedEvents.length > 10
? combinedEvents.sublist(combinedEvents.length - 10)
: combinedEvents;
return (
combat: combat.copyWith(
playerStats: playerStats,
@@ -961,6 +1052,7 @@ class ProgressService {
totalDamageTaken: totalDamageTaken,
turnsElapsed: turnsElapsed,
isActive: isActive,
recentEvents: recentEvents,
),
skillSystem: updatedSkillSystem,
);
@@ -977,6 +1069,10 @@ class ProgressService {
// 상실할 장비 개수 계산
final lostCount = state.equipment.equippedItems.length;
// 사망 직전 전투 이벤트 저장 (최대 10개)
final lastCombatEvents =
state.progress.currentCombat?.recentEvents ?? const [];
// 빈 장비 생성 (기본 무기만 유지)
final emptyEquipment = Equipment(
items: [
@@ -995,7 +1091,7 @@ class ProgressService {
bestIndex: 0,
);
// 사망 정보 생성
// 사망 정보 생성 (전투 로그 포함)
final deathInfo = DeathInfo(
cause: cause,
killerName: killerName,
@@ -1003,6 +1099,7 @@ class ProgressService {
goldAtDeath: state.inventory.gold,
levelAtDeath: state.traits.level,
timestamp: state.skillSystem.elapsedMs,
lastCombatEvents: lastCombatEvents,
);
// 전투 상태 초기화

View File

@@ -1,7 +1,13 @@
import 'dart:math';
import 'package:askiineverdie/data/class_data.dart';
import 'package:askiineverdie/data/race_data.dart';
import 'package:askiineverdie/src/core/engine/shop_service.dart';
import 'package:askiineverdie/src/core/model/class_traits.dart';
import 'package:askiineverdie/src/core/model/equipment_item.dart';
import 'package:askiineverdie/src/core/model/equipment_slot.dart';
import 'package:askiineverdie/src/core/model/game_state.dart';
import 'package:askiineverdie/src/core/model/race_traits.dart';
/// 부활 시스템 서비스 (Phase 4)
///
@@ -17,7 +23,7 @@ class ResurrectionService {
/// 플레이어 사망 처리
///
/// 1. 모든 장비 제거 (인벤토리로 이동하지 않음 - 상실)
/// 1. 1개의 랜덤 장비 제거 (제물로 바침)
/// 2. 전투 상태 초기화
/// 3. 사망 정보 기록
GameState processDeath({
@@ -25,32 +31,40 @@ class ResurrectionService {
required String killerName,
required DeathCause cause,
}) {
// 상실할 장비 개수 계산
final lostCount = state.equipment.equippedItems.length;
// 제물로 바칠 아이템 선택 (장착된 아이템 중 랜덤 1개)
final equippedItems = <int>[]; // 장착된 아이템의 슬롯 인덱스
for (var i = 0; i < Equipment.slotCount; i++) {
final item = state.equipment.getItemByIndex(i);
// 빈 슬롯과 기본 무기(Keyboard) 제외
if (item.isNotEmpty && item.name != 'Keyboard') {
equippedItems.add(i);
}
}
// 빈 장비 생성 (기본 무기만 유지)
final emptyEquipment = Equipment(
items: [
EquipmentItem.defaultWeapon(), // 무기 슬롯에 기본 Keyboard
EquipmentItem.empty(EquipmentSlot.shield),
EquipmentItem.empty(EquipmentSlot.helm),
EquipmentItem.empty(EquipmentSlot.hauberk),
EquipmentItem.empty(EquipmentSlot.brassairts),
EquipmentItem.empty(EquipmentSlot.vambraces),
EquipmentItem.empty(EquipmentSlot.gauntlets),
EquipmentItem.empty(EquipmentSlot.gambeson),
EquipmentItem.empty(EquipmentSlot.cuisses),
EquipmentItem.empty(EquipmentSlot.greaves),
EquipmentItem.empty(EquipmentSlot.sollerets),
],
bestIndex: 0,
);
String? lostItemName;
var newEquipment = state.equipment;
if (equippedItems.isNotEmpty) {
// 랜덤하게 1개 슬롯 선택
final random = Random();
final slotIndex = equippedItems[random.nextInt(equippedItems.length)];
final lostItem = state.equipment.getItemByIndex(slotIndex);
lostItemName = lostItem.name;
// 해당 슬롯만 빈 아이템으로 교체
final slot = EquipmentSlot.values[slotIndex];
newEquipment = state.equipment.setItemByIndex(
slotIndex,
EquipmentItem.empty(slot),
);
}
// 사망 정보 생성
final deathInfo = DeathInfo(
cause: cause,
killerName: killerName,
lostEquipmentCount: lostCount,
lostEquipmentCount: lostItemName != null ? 1 : 0,
lostItemName: lostItemName,
goldAtDeath: state.inventory.gold,
levelAtDeath: state.traits.level,
timestamp: state.skillSystem.elapsedMs,
@@ -62,7 +76,7 @@ class ResurrectionService {
);
return state.copyWith(
equipment: emptyEquipment,
equipment: newEquipment,
progress: progress,
deathInfo: deathInfo,
);
@@ -74,33 +88,37 @@ class ResurrectionService {
/// 플레이어 부활 처리
///
/// 1. HP/MP 전체 회복
/// 2. 골드로 구매 가능한 장비 자동 구매
/// 1. 골드로 구매 가능한 장비 자동 구매
/// 2. HP/MP 전체 회복 (장비/종족/클래스 보너스 포함)
/// 3. 사망 상태 해제
/// 4. 안전 지역으로 이동 태스크 설정
GameState processResurrection(GameState state) {
if (!state.isDead) return state;
// HP/MP 전체 회복
// 1. 먼저 장비 구매 (HP 계산에 필요)
final autoBuyResult = shopService.autoBuyForEmptySlots(
playerLevel: state.traits.level,
currentGold: state.inventory.gold,
currentEquipment: state.equipment,
);
// 장비 적용
var nextState = state.copyWith(
stats: state.stats.copyWith(
hpCurrent: state.stats.hpMax,
mpCurrent: state.stats.mpMax,
equipment: autoBuyResult.updatedEquipment,
inventory: state.inventory.copyWith(
gold: autoBuyResult.remainingGold,
),
);
// 빈 슬롯에 자동 장비 구매
final autoBuyResult = shopService.autoBuyForEmptySlots(
playerLevel: nextState.traits.level,
currentGold: nextState.inventory.gold,
currentEquipment: nextState.equipment,
);
// 2. 전체 HP/MP 계산 (장비 + 종족 + 클래스 보너스 포함)
final totalHpMax = _calculateTotalHpMax(nextState);
final totalMpMax = _calculateTotalMpMax(nextState);
// 결과 적용
// HP/MP 전체 회복
nextState = nextState.copyWith(
equipment: autoBuyResult.updatedEquipment,
inventory: nextState.inventory.copyWith(
gold: autoBuyResult.remainingGold,
stats: nextState.stats.copyWith(
hpCurrent: totalHpMax,
mpCurrent: totalMpMax,
),
clearDeathInfo: true, // 사망 상태 해제
);
@@ -115,6 +133,49 @@ class ResurrectionService {
return nextState;
}
/// 장비/종족/클래스 보너스를 포함한 전체 HP 계산
int _calculateTotalHpMax(GameState state) {
// 기본 HP + 장비 보너스
var totalHp = state.stats.hpMax + state.equipment.totalStats.hpBonus;
// 종족 HP 보너스 (예: Heap Troll +20%)
final race = RaceData.findById(state.traits.raceId);
if (race != null) {
final raceHpBonus = race.getPassiveValue(PassiveType.hpBonus);
if (raceHpBonus > 0) {
totalHp = (totalHp * (1 + raceHpBonus)).round();
}
}
// 클래스 HP 보너스 (예: Garbage Collector +30%)
final klass = ClassData.findById(state.traits.classId);
if (klass != null) {
final classHpBonus = klass.getPassiveValue(ClassPassiveType.hpBonus);
if (classHpBonus > 0) {
totalHp = (totalHp * (1 + classHpBonus)).round();
}
}
return totalHp;
}
/// 장비/종족/클래스 보너스를 포함한 전체 MP 계산
int _calculateTotalMpMax(GameState state) {
// 기본 MP + 장비 보너스
var totalMp = state.stats.mpMax + state.equipment.totalStats.mpBonus;
// 종족 MP 보너스 (예: Pointer Fairy +20%)
final race = RaceData.findById(state.traits.raceId);
if (race != null) {
final raceMpBonus = race.getPassiveValue(PassiveType.mpBonus);
if (raceMpBonus > 0) {
totalMp = (totalMp * (1 + raceMpBonus)).round();
}
}
return totalMp;
}
// ============================================================================
// 유틸리티
// ============================================================================

View File

@@ -0,0 +1,186 @@
import 'dart:async';
import 'package:askiineverdie/data/story_data.dart';
/// 스토리 이벤트 타입 (Story Event Type)
enum StoryEventType {
actStart, // Act 시작
actComplete, // Act 완료
bossEncounter, // 보스 조우
bossDefeat, // 보스 처치
ending, // 엔딩
}
/// 스토리 이벤트 (Story Event)
class StoryEvent {
const StoryEvent({
required this.type,
required this.act,
this.data,
});
final StoryEventType type;
final StoryAct act;
final Map<String, dynamic>? data;
}
/// 스토리 서비스 (Phase 9: Story Progression Management)
///
/// Act 전환, 시네마틱 트리거, 보스 조우 관리
class StoryService {
StoryService();
final _eventController = StreamController<StoryEvent>.broadcast();
/// 스토리 이벤트 스트림 (Story Event Stream)
Stream<StoryEvent> get events => _eventController.stream;
// 현재 Act 추적
StoryAct _currentAct = StoryAct.prologue;
bool _hasSeenPrologue = false;
final Set<StoryAct> _completedActs = {};
/// 현재 Act (Current Act)
StoryAct get currentAct => _currentAct;
/// 프롤로그 시청 여부
bool get hasSeenPrologue => _hasSeenPrologue;
/// 완료된 Act 목록
Set<StoryAct> get completedActs => Set.unmodifiable(_completedActs);
/// 레벨 변화 감지 및 Act 전환 처리 (Process Level Change)
///
/// 레벨업 시 호출하여 Act 전환 이벤트 트리거
StoryEvent? processLevelChange(int oldLevel, int newLevel) {
final oldAct = getActForLevel(oldLevel);
final newAct = getActForLevel(newLevel);
// 새 게임 시작 (프롤로그)
if (oldLevel == 0 && newLevel == 1 && !_hasSeenPrologue) {
_hasSeenPrologue = true;
_currentAct = StoryAct.prologue;
final event = StoryEvent(
type: StoryEventType.actStart,
act: StoryAct.prologue,
);
_eventController.add(event);
return event;
}
// Act 전환 감지
if (newAct != oldAct && newAct != _currentAct) {
// 이전 Act 완료 처리
if (_currentAct != StoryAct.prologue) {
_completedActs.add(_currentAct);
_eventController.add(StoryEvent(
type: StoryEventType.actComplete,
act: _currentAct,
));
}
// 새 Act 시작
_currentAct = newAct;
final event = StoryEvent(
type: StoryEventType.actStart,
act: newAct,
);
_eventController.add(event);
return event;
}
return null;
}
/// 보스 조우 처리 (Process Boss Encounter)
void processBossEncounter(String monsterName) {
// BOSS: 접두사가 있는 몬스터인지 확인
if (!monsterName.startsWith('BOSS:')) return;
final event = StoryEvent(
type: StoryEventType.bossEncounter,
act: _currentAct,
data: {'bossName': monsterName},
);
_eventController.add(event);
}
/// 보스 처치 처리 (Process Boss Defeat)
void processBossDefeat(String monsterName) {
if (!monsterName.startsWith('BOSS:')) return;
final event = StoryEvent(
type: StoryEventType.bossDefeat,
act: _currentAct,
data: {'bossName': monsterName},
);
_eventController.add(event);
// 최종 보스 처치 시 엔딩
if (monsterName.contains('Primordial Glitch')) {
_triggerEnding();
}
}
/// 엔딩 트리거 (Trigger Ending)
void _triggerEnding() {
_completedActs.add(StoryAct.act5);
_currentAct = StoryAct.ending;
_eventController.add(StoryEvent(
type: StoryEventType.ending,
act: StoryAct.ending,
));
}
/// 시네마틱 데이터 가져오기 (Get Cinematic Data)
List<CinematicStep> getCinematicSteps(StoryAct act) {
return cinematicData[act] ?? [];
}
/// Act 제목 가져오기 (Get Act Title)
String getActTitle(StoryAct act) {
return actTitles[act] ?? 'Unknown';
}
/// Act 보스 이름 가져오기 (Get Act Boss Name)
String? getActBossName(StoryAct act) {
return actBossNames[act];
}
/// 저장 데이터로 상태 복원 (Restore State from Save)
void restoreState({
required StoryAct currentAct,
required bool hasSeenPrologue,
required Set<StoryAct> completedActs,
}) {
_currentAct = currentAct;
_hasSeenPrologue = hasSeenPrologue;
_completedActs
..clear()
..addAll(completedActs);
}
/// 현재 상태를 저장 데이터로 변환 (Convert to Save Data)
Map<String, dynamic> toSaveData() {
return {
'currentAct': _currentAct.index,
'hasSeenPrologue': _hasSeenPrologue,
'completedActs': _completedActs.map((a) => a.index).toList(),
};
}
/// 저장 데이터에서 복원 (Restore from Save Data)
void fromSaveData(Map<String, dynamic> data) {
_currentAct = StoryAct.values[data['currentAct'] as int? ?? 0];
_hasSeenPrologue = data['hasSeenPrologue'] as bool? ?? false;
final completedIndices = data['completedActs'] as List<dynamic>? ?? [];
_completedActs
..clear()
..addAll(completedIndices.map((i) => StoryAct.values[i as int]));
}
/// 서비스 정리 (Dispose)
void dispose() {
_eventController.close();
}
}

View File

@@ -0,0 +1,191 @@
/// 전투 이벤트 타입 (Combat Event Type)
enum CombatEventType {
/// 플레이어가 몬스터 공격
playerAttack,
/// 몬스터가 플레이어 공격
monsterAttack,
/// 플레이어 회피
playerEvade,
/// 몬스터 회피
monsterEvade,
/// 플레이어 방패 방어
playerBlock,
/// 플레이어 무기 쳐내기
playerParry,
/// 플레이어 스킬 사용
playerSkill,
/// 플레이어 회복
playerHeal,
/// 플레이어 버프
playerBuff,
}
/// 전투 이벤트 (Combat Event)
///
/// 개별 공격/방어/스킬 사용 등의 전투 행동을 기록
class CombatEvent {
const CombatEvent({
required this.type,
required this.timestamp,
this.damage = 0,
this.healAmount = 0,
this.isCritical = false,
this.skillName,
this.targetName,
});
/// 이벤트 타입
final CombatEventType type;
/// 발생 시간 (elapsedMs)
final int timestamp;
/// 데미지 (0이면 미스/회피)
final int damage;
/// 회복량 (회복 이벤트용)
final int healAmount;
/// 크리티컬 여부
final bool isCritical;
/// 사용한 스킬 이름 (스킬 이벤트용)
final String? skillName;
/// 대상 이름 (몬스터 또는 플레이어)
final String? targetName;
/// 플레이어 공격 이벤트 생성
factory CombatEvent.playerAttack({
required int timestamp,
required int damage,
required String targetName,
bool isCritical = false,
}) {
return CombatEvent(
type: CombatEventType.playerAttack,
timestamp: timestamp,
damage: damage,
targetName: targetName,
isCritical: isCritical,
);
}
/// 몬스터 공격 이벤트 생성
factory CombatEvent.monsterAttack({
required int timestamp,
required int damage,
required String attackerName,
}) {
return CombatEvent(
type: CombatEventType.monsterAttack,
timestamp: timestamp,
damage: damage,
targetName: attackerName,
);
}
/// 플레이어 회피 이벤트 생성
factory CombatEvent.playerEvade({
required int timestamp,
required String attackerName,
}) {
return CombatEvent(
type: CombatEventType.playerEvade,
timestamp: timestamp,
targetName: attackerName,
);
}
/// 몬스터 회피 이벤트 생성
factory CombatEvent.monsterEvade({
required int timestamp,
required String targetName,
}) {
return CombatEvent(
type: CombatEventType.monsterEvade,
timestamp: timestamp,
targetName: targetName,
);
}
/// 플레이어 방패 방어 이벤트 생성
factory CombatEvent.playerBlock({
required int timestamp,
required int reducedDamage,
required String attackerName,
}) {
return CombatEvent(
type: CombatEventType.playerBlock,
timestamp: timestamp,
damage: reducedDamage,
targetName: attackerName,
);
}
/// 플레이어 무기 쳐내기 이벤트 생성
factory CombatEvent.playerParry({
required int timestamp,
required int reducedDamage,
required String attackerName,
}) {
return CombatEvent(
type: CombatEventType.playerParry,
timestamp: timestamp,
damage: reducedDamage,
targetName: attackerName,
);
}
/// 스킬 사용 이벤트 생성
factory CombatEvent.playerSkill({
required int timestamp,
required String skillName,
required int damage,
required String targetName,
bool isCritical = false,
}) {
return CombatEvent(
type: CombatEventType.playerSkill,
timestamp: timestamp,
skillName: skillName,
damage: damage,
targetName: targetName,
isCritical: isCritical,
);
}
/// 회복 이벤트 생성
factory CombatEvent.playerHeal({
required int timestamp,
required int healAmount,
String? skillName,
}) {
return CombatEvent(
type: CombatEventType.playerHeal,
timestamp: timestamp,
healAmount: healAmount,
skillName: skillName,
);
}
/// 버프 이벤트 생성
factory CombatEvent.playerBuff({
required int timestamp,
required String skillName,
}) {
return CombatEvent(
type: CombatEventType.playerBuff,
timestamp: timestamp,
skillName: skillName,
);
}
}

View File

@@ -1,3 +1,4 @@
import 'package:askiineverdie/src/core/model/combat_event.dart';
import 'package:askiineverdie/src/core/model/combat_stats.dart';
import 'package:askiineverdie/src/core/model/monster_combat_stats.dart';
@@ -15,6 +16,7 @@ class CombatState {
required this.totalDamageTaken,
required this.turnsElapsed,
required this.isActive,
this.recentEvents = const [],
});
/// 플레이어 전투 스탯
@@ -41,6 +43,9 @@ class CombatState {
/// 전투 활성화 여부
final bool isActive;
/// 최근 전투 이벤트 목록 (최대 10개)
final List<CombatEvent> recentEvents;
// ============================================================================
// 유틸리티
// ============================================================================
@@ -69,6 +74,7 @@ class CombatState {
int? totalDamageTaken,
int? turnsElapsed,
bool? isActive,
List<CombatEvent>? recentEvents,
}) {
return CombatState(
playerStats: playerStats ?? this.playerStats,
@@ -81,6 +87,7 @@ class CombatState {
totalDamageTaken: totalDamageTaken ?? this.totalDamageTaken,
turnsElapsed: turnsElapsed ?? this.turnsElapsed,
isActive: isActive ?? this.isActive,
recentEvents: recentEvents ?? this.recentEvents,
);
}

View File

@@ -1,5 +1,6 @@
import 'dart:collection';
import 'package:askiineverdie/src/core/model/combat_event.dart';
import 'package:askiineverdie/src/core/model/combat_state.dart';
import 'package:askiineverdie/src/core/model/equipment_item.dart';
import 'package:askiineverdie/src/core/model/equipment_slot.dart';
@@ -107,7 +108,7 @@ class GameState {
/// 사망 정보 (Phase 4)
///
/// 사망 시점의 정보와 상실한 장비 목록을 기록
/// 사망 시점의 정보와 상실한 아이템을 기록
class DeathInfo {
const DeathInfo({
required this.cause,
@@ -116,6 +117,8 @@ class DeathInfo {
required this.goldAtDeath,
required this.levelAtDeath,
required this.timestamp,
this.lostItemName,
this.lastCombatEvents = const [],
});
/// 사망 원인
@@ -124,9 +127,12 @@ class DeathInfo {
/// 사망시킨 몬스터/원인 이름
final String killerName;
/// 상실한 장비 개수
/// 상실한 장비 개수 (0 또는 1)
final int lostEquipmentCount;
/// 제물로 바친 아이템 이름 (null이면 없음)
final String? lostItemName;
/// 사망 시점 골드
final int goldAtDeath;
@@ -136,21 +142,28 @@ class DeathInfo {
/// 사망 시각 (밀리초)
final int timestamp;
/// 사망 직전 전투 이벤트 (최대 10개)
final List<CombatEvent> lastCombatEvents;
DeathInfo copyWith({
DeathCause? cause,
String? killerName,
int? lostEquipmentCount,
String? lostItemName,
int? goldAtDeath,
int? levelAtDeath,
int? timestamp,
List<CombatEvent>? lastCombatEvents,
}) {
return DeathInfo(
cause: cause ?? this.cause,
killerName: killerName ?? this.killerName,
lostEquipmentCount: lostEquipmentCount ?? this.lostEquipmentCount,
lostItemName: lostItemName ?? this.lostItemName,
goldAtDeath: goldAtDeath ?? this.goldAtDeath,
levelAtDeath: levelAtDeath ?? this.levelAtDeath,
timestamp: timestamp ?? this.timestamp,
lastCombatEvents: lastCombatEvents ?? this.lastCombatEvents,
);
}
}
@@ -296,15 +309,28 @@ class Traits {
required this.level,
required this.motto,
required this.guild,
this.raceId = '',
this.classId = '',
});
final String name;
/// 종족 표시 이름 (예: "Kernel Giant")
final String race;
/// 클래스 표시 이름 (예: "Bug Hunter")
final String klass;
final int level;
final String motto;
final String guild;
/// 종족 ID (Phase 5, 예: "kernel_giant")
final String raceId;
/// 클래스 ID (Phase 5, 예: "bug_hunter")
final String classId;
factory Traits.empty() => const Traits(
name: '',
race: '',
@@ -312,6 +338,8 @@ class Traits {
level: 1,
motto: '',
guild: '',
raceId: '',
classId: '',
);
Traits copyWith({
@@ -321,6 +349,8 @@ class Traits {
int? level,
String? motto,
String? guild,
String? raceId,
String? classId,
}) {
return Traits(
name: name ?? this.name,
@@ -329,6 +359,8 @@ class Traits {
level: level ?? this.level,
motto: motto ?? this.motto,
guild: guild ?? this.guild,
raceId: raceId ?? this.raceId,
classId: classId ?? this.classId,
);
}
}

View File

@@ -0,0 +1,192 @@
import 'package:askiineverdie/src/core/model/combat_stats.dart';
import 'package:askiineverdie/src/core/model/game_state.dart';
/// 명예의 전당 엔트리 (Phase 10: Hall of Fame Entry)
///
/// 게임 클리어 시 저장되는 캐릭터 정보
class HallOfFameEntry {
const HallOfFameEntry({
required this.id,
required this.characterName,
required this.race,
required this.klass,
required this.level,
required this.totalPlayTimeMs,
required this.totalDeaths,
required this.monstersKilled,
required this.questsCompleted,
required this.clearedAt,
this.finalStats,
this.finalEquipment,
});
/// 고유 ID (UUID)
final String id;
/// 캐릭터 이름
final String characterName;
/// 종족
final String race;
/// 클래스
final String klass;
/// 최종 레벨
final int level;
/// 총 플레이 시간 (밀리초)
final int totalPlayTimeMs;
/// 총 사망 횟수
final int totalDeaths;
/// 처치한 몬스터 수
final int monstersKilled;
/// 완료한 퀘스트 수
final int questsCompleted;
/// 클리어 일시
final DateTime clearedAt;
/// 최종 전투 스탯 (향후 아스키 아레나용)
final CombatStats? finalStats;
/// 최종 장비 목록 (향후 아스키 아레나용)
final Map<String, String>? finalEquipment;
/// 플레이 시간을 Duration으로 변환
Duration get totalPlayTime => Duration(milliseconds: totalPlayTimeMs);
/// 플레이 시간 포맷팅 (HH:MM:SS)
String get formattedPlayTime {
final hours = totalPlayTime.inHours;
final minutes = totalPlayTime.inMinutes % 60;
final seconds = totalPlayTime.inSeconds % 60;
return '${hours.toString().padLeft(2, '0')}:'
'${minutes.toString().padLeft(2, '0')}:'
'${seconds.toString().padLeft(2, '0')}';
}
/// 클리어 날짜 포맷팅 (YYYY.MM.DD)
String get formattedClearedDate {
return '${clearedAt.year}.${clearedAt.month.toString().padLeft(2, '0')}.'
'${clearedAt.day.toString().padLeft(2, '0')}';
}
/// GameState에서 HallOfFameEntry 생성
factory HallOfFameEntry.fromGameState({
required GameState state,
required int totalDeaths,
required int monstersKilled,
CombatStats? combatStats,
}) {
return HallOfFameEntry(
id: DateTime.now().millisecondsSinceEpoch.toString(),
characterName: state.traits.name,
race: state.traits.race,
klass: state.traits.klass,
level: state.traits.level,
totalPlayTimeMs: state.skillSystem.elapsedMs,
totalDeaths: totalDeaths,
monstersKilled: monstersKilled,
questsCompleted: state.progress.questCount,
clearedAt: DateTime.now(),
finalStats: combatStats,
finalEquipment: {
'weapon': state.equipment.weapon,
'shield': state.equipment.shield,
'helm': state.equipment.helm,
'hauberk': state.equipment.hauberk,
'brassairts': state.equipment.brassairts,
'vambraces': state.equipment.vambraces,
'gauntlets': state.equipment.gauntlets,
'gambeson': state.equipment.gambeson,
'cuisses': state.equipment.cuisses,
'greaves': state.equipment.greaves,
'sollerets': state.equipment.sollerets,
},
);
}
/// JSON으로 직렬화
Map<String, dynamic> toJson() {
return {
'id': id,
'characterName': characterName,
'race': race,
'klass': klass,
'level': level,
'totalPlayTimeMs': totalPlayTimeMs,
'totalDeaths': totalDeaths,
'monstersKilled': monstersKilled,
'questsCompleted': questsCompleted,
'clearedAt': clearedAt.toIso8601String(),
'finalEquipment': finalEquipment,
};
}
/// JSON에서 역직렬화
factory HallOfFameEntry.fromJson(Map<String, dynamic> json) {
return HallOfFameEntry(
id: json['id'] as String,
characterName: json['characterName'] as String,
race: json['race'] as String,
klass: json['klass'] as String,
level: json['level'] as int,
totalPlayTimeMs: json['totalPlayTimeMs'] as int,
totalDeaths: json['totalDeaths'] as int? ?? 0,
monstersKilled: json['monstersKilled'] as int? ?? 0,
questsCompleted: json['questsCompleted'] as int? ?? 0,
clearedAt: DateTime.parse(json['clearedAt'] as String),
finalEquipment: json['finalEquipment'] != null
? Map<String, String>.from(json['finalEquipment'] as Map)
: null,
);
}
}
/// 명예의 전당 (Hall of Fame)
///
/// 클리어한 캐릭터 목록 관리
class HallOfFame {
const HallOfFame({required this.entries});
/// 명예의 전당 엔트리 목록 (클리어 시간 역순)
final List<HallOfFameEntry> entries;
/// 빈 명예의 전당
factory HallOfFame.empty() => const HallOfFame(entries: []);
/// 새 엔트리 추가
HallOfFame addEntry(HallOfFameEntry entry) {
final newEntries = List<HallOfFameEntry>.from(entries)
..add(entry)
..sort((a, b) => b.clearedAt.compareTo(a.clearedAt));
return HallOfFame(entries: newEntries);
}
/// 엔트리 수
int get count => entries.length;
/// 비어있는지 확인
bool get isEmpty => entries.isEmpty;
/// JSON으로 직렬화
Map<String, dynamic> toJson() {
return {
'entries': entries.map((e) => e.toJson()).toList(),
};
}
/// JSON에서 역직렬화
factory HallOfFame.fromJson(Map<String, dynamic> json) {
final entriesJson = json['entries'] as List<dynamic>? ?? [];
return HallOfFame(
entries: entriesJson
.map((e) => HallOfFameEntry.fromJson(e as Map<String, dynamic>))
.toList(),
);
}
}

View File

@@ -1,3 +1,5 @@
import 'package:askiineverdie/src/core/util/balance_constants.dart';
/// 몬스터 공격 속도 타입
enum MonsterSpeedType {
/// 빠름 (600ms)
@@ -127,22 +129,15 @@ class MonsterCombatStats {
/// [name] 몬스터 표시 이름
/// [level] 몬스터 레벨 (원본 데이터 기준)
/// [speedType] 공격 속도 타입 (기본: normal)
/// [monsterType] 몬스터 타입 (기본: normal)
factory MonsterCombatStats.fromLevel({
required String name,
required int level,
MonsterSpeedType speedType = MonsterSpeedType.normal,
MonsterType monsterType = MonsterType.normal,
}) {
// 레벨 기반 스탯 스케일링
// 레벨 1 기준으로 선형/비선형 증가
// HP: 레벨 * 15 + 기본값 20
final hpMax = 20 + level * 15;
// 공격력: 레벨 * 3 + 기본값 5
final atk = 5 + level * 3;
// 방어력: 레벨 * 1.5 + 기본값 2
final def = 2 + (level * 1.5).round();
// balance_constants.dart의 MonsterBaseStats 사용
final baseStats = MonsterBaseStats.generate(level, monsterType);
// 크리티컬 확률: 레벨에 따라 천천히 증가 (0.02 ~ 0.3)
final criRate = (0.02 + level * 0.003).clamp(0.02, 0.3);
@@ -163,22 +158,19 @@ class MonsterCombatStats {
MonsterSpeedType.slow => 1400,
};
// 경험치 보상: 레벨 기반
final expReward = 10 + level * 5;
return MonsterCombatStats(
name: name,
level: level,
atk: atk,
def: def,
hpMax: hpMax,
hpCurrent: hpMax,
atk: baseStats.atk,
def: baseStats.def,
hpMax: baseStats.hp,
hpCurrent: baseStats.hp,
criRate: criRate,
criDamage: criDamage,
evasion: evasion,
accuracy: accuracy,
attackDelayMs: attackDelayMs,
expReward: expReward,
expReward: baseStats.exp,
);
}

View File

@@ -53,6 +53,8 @@ class GameSave {
'level': traits.level,
'motto': traits.motto,
'guild': traits.guild,
'raceId': traits.raceId,
'classId': traits.classId,
},
'stats': {
'str': stats.str,
@@ -148,6 +150,8 @@ class GameSave {
level: traitsJson['level'] as int? ?? 1,
motto: traitsJson['motto'] as String? ?? '',
guild: traitsJson['guild'] as String? ?? '',
raceId: traitsJson['raceId'] as String? ?? '',
classId: traitsJson['classId'] as String? ?? '',
),
stats: Stats(
str: statsJson['str'] as int? ?? 0,

View File

@@ -0,0 +1,153 @@
import 'dart:async';
/// 알림 타입 (Notification Type)
enum NotificationType {
levelUp, // 레벨업
questComplete, // 퀘스트 완료
actComplete, // 막(Act) 완료
newSpell, // 새 주문 습득
newEquipment, // 새 장비 획득
bossDefeat, // 보스 처치
}
/// 게임 알림 데이터 (Game Notification)
class GameNotification {
const GameNotification({
required this.type,
required this.title,
this.subtitle,
this.data,
this.duration = const Duration(seconds: 3),
});
final NotificationType type;
final String title;
final String? subtitle;
final Map<String, dynamic>? data;
final Duration duration;
}
/// 알림 서비스 (Phase 8: 이벤트 기반 알림 관리)
///
/// 게임 이벤트(레벨업, 퀘스트 완료 등)를 큐에 추가하고
/// 순차적으로 UI에 표시
class NotificationService {
NotificationService();
final _notificationController =
StreamController<GameNotification>.broadcast();
final _dismissController = StreamController<void>.broadcast();
/// 알림 스트림 (Notification Stream)
Stream<GameNotification> get notifications => _notificationController.stream;
/// 알림 닫기 스트림
Stream<void> get dismissals => _dismissController.stream;
/// 알림 큐 (대기 중인 알림)
final List<GameNotification> _queue = [];
bool _isShowing = false;
/// 알림 추가 (Add Notification)
void show(GameNotification notification) {
_queue.add(notification);
_processQueue();
}
/// 레벨업 알림 (Level Up Notification)
void showLevelUp(int newLevel) {
show(GameNotification(
type: NotificationType.levelUp,
title: 'LEVEL UP!',
subtitle: 'Level $newLevel',
data: {'level': newLevel},
duration: const Duration(seconds: 2),
));
}
/// 퀘스트 완료 알림
void showQuestComplete(String questName) {
show(GameNotification(
type: NotificationType.questComplete,
title: 'QUEST COMPLETE!',
subtitle: questName,
data: {'quest': questName},
duration: const Duration(seconds: 2),
));
}
/// 막 완료 알림 (Act Complete)
/// actNumber: 0=프롤로그, 1=Act I, 2=Act II, ...
void showActComplete(int actNumber) {
final title = actNumber == 0
? 'PROLOGUE COMPLETE!'
: 'ACT $actNumber COMPLETE!';
show(GameNotification(
type: NotificationType.actComplete,
title: title,
duration: const Duration(seconds: 3),
));
}
/// 새 주문 알림
void showNewSpell(String spellName) {
show(GameNotification(
type: NotificationType.newSpell,
title: 'NEW SPELL!',
subtitle: spellName,
data: {'spell': spellName},
duration: const Duration(seconds: 2),
));
}
/// 새 장비 알림
void showNewEquipment(String equipmentName, String slot) {
show(GameNotification(
type: NotificationType.newEquipment,
title: 'NEW EQUIPMENT!',
subtitle: equipmentName,
data: {'equipment': equipmentName, 'slot': slot},
duration: const Duration(seconds: 2),
));
}
/// 보스 처치 알림
void showBossDefeat(String bossName) {
show(GameNotification(
type: NotificationType.bossDefeat,
title: 'BOSS DEFEATED!',
subtitle: bossName,
data: {'boss': bossName},
duration: const Duration(seconds: 3),
));
}
/// 큐 처리 (Process Queue)
void _processQueue() {
if (_isShowing || _queue.isEmpty) return;
_isShowing = true;
final notification = _queue.removeAt(0);
_notificationController.add(notification);
// 지정된 시간 후 자동 닫기
Future.delayed(notification.duration, () {
_dismissController.add(null);
_isShowing = false;
_processQueue(); // 다음 알림 처리
});
}
/// 현재 알림 즉시 닫기
void dismiss() {
_dismissController.add(null);
_isShowing = false;
_processQueue();
}
/// 서비스 정리 (Dispose)
void dispose() {
_notificationController.close();
_dismissController.close();
}
}

View File

@@ -0,0 +1,81 @@
import 'dart:convert';
import 'dart:io';
import 'package:askiineverdie/src/core/model/hall_of_fame.dart';
import 'package:path_provider/path_provider.dart';
/// 명예의 전당 저장소 (Phase 10: Hall of Fame Storage)
///
/// 명예의 전당 데이터 저장/로드 관리
class HallOfFameStorage {
HallOfFameStorage();
static const String _fileName = 'hall_of_fame.json';
Directory? _storageDir;
Future<Directory> _getStorageDir() async {
if (_storageDir != null) return _storageDir!;
_storageDir = await getApplicationSupportDirectory();
return _storageDir!;
}
File _getFile(Directory dir) {
return File('${dir.path}/$_fileName');
}
/// 명예의 전당 로드
Future<HallOfFame> load() async {
try {
final dir = await _getStorageDir();
final file = _getFile(dir);
if (!await file.exists()) {
return HallOfFame.empty();
}
final content = await file.readAsString();
final json = jsonDecode(content) as Map<String, dynamic>;
return HallOfFame.fromJson(json);
} catch (e) {
// 파일이 없거나 손상된 경우 빈 명예의 전당 반환
return HallOfFame.empty();
}
}
/// 명예의 전당 저장
Future<bool> save(HallOfFame hallOfFame) async {
try {
final dir = await _getStorageDir();
final file = _getFile(dir);
final content = jsonEncode(hallOfFame.toJson());
await file.writeAsString(content);
return true;
} catch (e) {
return false;
}
}
/// 새 엔트리 추가 및 저장
Future<bool> addEntry(HallOfFameEntry entry) async {
final hallOfFame = await load();
final updated = hallOfFame.addEntry(entry);
return save(updated);
}
/// 명예의 전당 초기화 (테스트용)
Future<bool> clear() async {
try {
final dir = await _getStorageDir();
final file = _getFile(dir);
if (await file.exists()) {
await file.delete();
}
return true;
} catch (e) {
return false;
}
}
}

View File

@@ -0,0 +1,419 @@
/// 밸런스 상수 정의 (balance constants)
///
/// Phase 6: 레벨 1-100 구간의 난이도 곡선 및 스케일링 상수
library;
/// 경험치 관련 상수 (experience constants)
class ExpConstants {
ExpConstants._();
/// 기본 경험치 값
static const int baseExp = 100;
/// 레벨당 경험치 증가율 (1.15 = 15% 증가)
static const double expGrowthRate = 1.15;
/// 레벨업에 필요한 경험치 계산
///
/// 공식: baseExp * (expGrowthRate ^ level)
/// 레벨 10: ~405 exp
/// 레벨 50: ~108,366 exp
/// 레벨 100: ~11,739,085 exp
static int requiredExp(int level) {
if (level <= 0) return baseExp;
return (baseExp * _pow(expGrowthRate, level)).round();
}
/// 효율적인 거듭제곱 계산
static double _pow(double base, int exponent) {
double result = 1.0;
for (int i = 0; i < exponent; i++) {
result *= base;
}
return result;
}
}
/// 몬스터 타입 (monster type)
enum MonsterType {
/// 일반 몬스터
normal,
/// 정예 몬스터 (Elite)
elite,
/// 미니보스
miniboss,
/// 보스
boss,
/// 최종 보스
finalBoss,
}
/// 몬스터 타입별 배율 (monster type multipliers)
class MonsterTypeMultiplier {
const MonsterTypeMultiplier({
required this.hp,
required this.atk,
required this.def,
required this.exp,
required this.gold,
});
final double hp;
final double atk;
final double def;
final double exp;
final double gold;
/// 타입별 배율 가져오기
static MonsterTypeMultiplier forType(MonsterType type) {
return switch (type) {
MonsterType.normal => normal,
MonsterType.elite => elite,
MonsterType.miniboss => miniboss,
MonsterType.boss => boss,
MonsterType.finalBoss => finalBoss,
};
}
/// 일반: 모든 스탯 1.0배
static const normal = MonsterTypeMultiplier(
hp: 1.0,
atk: 1.0,
def: 1.0,
exp: 1.0,
gold: 1.0,
);
/// 정예: HP 2배, ATK 1.3배, DEF 1.2배, 보상 2배
static const elite = MonsterTypeMultiplier(
hp: 2.0,
atk: 1.3,
def: 1.2,
exp: 2.0,
gold: 2.0,
);
/// 미니보스: HP 5배, ATK/DEF 1.5배, 보상 5배
static const miniboss = MonsterTypeMultiplier(
hp: 5.0,
atk: 1.5,
def: 1.5,
exp: 5.0,
gold: 5.0,
);
/// 보스: HP 10배, ATK/DEF 2배, EXP 15배, GOLD 10배
static const boss = MonsterTypeMultiplier(
hp: 10.0,
atk: 2.0,
def: 2.0,
exp: 15.0,
gold: 10.0,
);
/// 최종 보스: HP 20배, ATK/DEF 2.5배, EXP 50배, GOLD 30배
static const finalBoss = MonsterTypeMultiplier(
hp: 20.0,
atk: 2.5,
def: 2.5,
exp: 50.0,
gold: 30.0,
);
}
/// 몬스터 기본 스탯 (monster base stats)
class MonsterBaseStats {
const MonsterBaseStats({
required this.hp,
required this.atk,
required this.def,
required this.exp,
required this.gold,
});
final int hp;
final int atk;
final int def;
final int exp;
final int gold;
/// 레벨 기반 기본 스탯 생성
///
/// HP: 50 + level * 20 + (level^2 / 5)
/// ATK: 5 + level * 3
/// DEF: 2 + level * 2
/// EXP: 10 + level * 5
/// GOLD: 5 + level * 3
factory MonsterBaseStats.forLevel(int level) {
return MonsterBaseStats(
hp: 50 + level * 20 + (level * level ~/ 5),
atk: 5 + level * 3,
def: 2 + level * 2,
exp: 10 + level * 5,
gold: 5 + level * 3,
);
}
/// 타입 배율 적용
MonsterBaseStats applyType(MonsterType type) {
final mult = MonsterTypeMultiplier.forType(type);
return MonsterBaseStats(
hp: (hp * mult.hp).round(),
atk: (atk * mult.atk).round(),
def: (def * mult.def).round(),
exp: (exp * mult.exp).round(),
gold: (gold * mult.gold).round(),
);
}
/// 레벨과 타입을 기반으로 몬스터 스탯 생성
static MonsterBaseStats generate(int level, MonsterType type) {
return MonsterBaseStats.forLevel(level).applyType(type);
}
}
/// 보스 특수 능력 타입 (boss ability type)
enum BossAbilityType {
/// 연속 공격 (Syntax Error Dragon)
multiAttack,
/// HP 회복 (Memory Leak Hydra)
regeneration,
/// 보호막 (Buffer Overflow Titan)
shield,
/// 스턴 공격 (Kernel Panic Archon)
stunAttack,
/// 페이즈 전환 (Glitch God)
phaseShift,
}
/// 보스 스탯 (boss stats)
class BossStats extends MonsterBaseStats {
const BossStats({
required super.hp,
required super.atk,
required super.def,
required super.exp,
required super.gold,
required this.phases,
required this.enrageThreshold,
required this.enrageMultiplier,
required this.hasShield,
required this.shieldAmount,
required this.abilities,
});
/// 페이즈 수 (글리치 신은 5페이즈)
final int phases;
/// 분노 발동 HP % (0.3 = 30%)
final double enrageThreshold;
/// 분노 시 스탯 배율
final double enrageMultiplier;
/// 보호막 보유 여부
final bool hasShield;
/// 보호막 수치
final int shieldAmount;
/// 특수 능력 목록
final List<BossAbilityType> abilities;
/// Syntax Error Dragon (Act I 보스, 레벨 20)
static BossStats syntaxErrorDragon(int baseLevel) {
final base = MonsterBaseStats.generate(baseLevel, MonsterType.boss);
return BossStats(
hp: base.hp,
atk: base.atk,
def: base.def,
exp: base.exp,
gold: base.gold,
phases: 3,
enrageThreshold: 0.2,
enrageMultiplier: 1.5,
hasShield: false,
shieldAmount: 0,
abilities: [BossAbilityType.multiAttack],
);
}
/// Memory Leak Hydra (Act II 보스, 레벨 40)
static BossStats memoryLeakHydra(int baseLevel) {
final base = MonsterBaseStats.generate(baseLevel, MonsterType.boss);
return BossStats(
hp: base.hp,
atk: base.atk,
def: base.def,
exp: base.exp,
gold: base.gold,
phases: 2,
enrageThreshold: 0.3,
enrageMultiplier: 1.3,
hasShield: false,
shieldAmount: 0,
abilities: [BossAbilityType.regeneration],
);
}
/// Buffer Overflow Titan (Act III 보스, 레벨 60)
static BossStats bufferOverflowTitan(int baseLevel) {
final base = MonsterBaseStats.generate(baseLevel, MonsterType.boss);
return BossStats(
hp: base.hp,
atk: base.atk,
def: base.def,
exp: base.exp,
gold: base.gold,
phases: 2,
enrageThreshold: 0.25,
enrageMultiplier: 1.4,
hasShield: true,
shieldAmount: (base.hp * 0.3).round(),
abilities: [BossAbilityType.shield],
);
}
/// Kernel Panic Archon (Act IV 보스, 레벨 80)
static BossStats kernelPanicArchon(int baseLevel) {
final base = MonsterBaseStats.generate(baseLevel, MonsterType.boss);
return BossStats(
hp: base.hp,
atk: base.atk,
def: base.def,
exp: base.exp,
gold: base.gold,
phases: 3,
enrageThreshold: 0.2,
enrageMultiplier: 1.6,
hasShield: true,
shieldAmount: (base.hp * 0.2).round(),
abilities: [BossAbilityType.stunAttack],
);
}
/// Glitch God (최종 보스, 레벨 100)
static BossStats glitchGod(int baseLevel) {
final base = MonsterBaseStats.generate(baseLevel, MonsterType.finalBoss);
return BossStats(
hp: base.hp,
atk: base.atk,
def: base.def,
exp: base.exp,
gold: base.gold,
phases: 5,
enrageThreshold: 0.1,
enrageMultiplier: 2.0,
hasShield: true,
shieldAmount: (base.hp * 0.5).round(),
abilities: [
BossAbilityType.phaseShift,
BossAbilityType.multiAttack,
BossAbilityType.regeneration,
BossAbilityType.stunAttack,
],
);
}
}
/// 레벨 구간별 설정 (level tier settings)
class LevelTierSettings {
const LevelTierSettings({
required this.minLevel,
required this.maxLevel,
required this.name,
required this.targetDeathRate,
required this.estimatedPlayTimeMinutes,
});
final int minLevel;
final int maxLevel;
final String name;
/// 목표 사망 확률 (전투당, 0.01 = 1%)
final double targetDeathRate;
/// 예상 플레이 시간 (분)
final int estimatedPlayTimeMinutes;
/// 초반 (레벨 1-20): 튜토리얼
static const early = LevelTierSettings(
minLevel: 1,
maxLevel: 20,
name: '초반',
targetDeathRate: 0.02, // 1-3%
estimatedPlayTimeMinutes: 90, // 1-2시간
);
/// 중반 (레벨 21-50): 본격적인 성장
static const mid = LevelTierSettings(
minLevel: 21,
maxLevel: 50,
name: '중반',
targetDeathRate: 0.04, // 3-5%
estimatedPlayTimeMinutes: 240, // 3-5시간
);
/// 후반 (레벨 51-80): 고급 장비
static const late = LevelTierSettings(
minLevel: 51,
maxLevel: 80,
name: '후반',
targetDeathRate: 0.075, // 5-10%
estimatedPlayTimeMinutes: 390, // 5-8시간
);
/// 엔드게임 (레벨 81-100): 최종 보스
static const endgame = LevelTierSettings(
minLevel: 81,
maxLevel: 100,
name: '엔드게임',
targetDeathRate: 0.15, // 10-20%
estimatedPlayTimeMinutes: 240, // 3-5시간
);
/// 모든 레벨 구간
static const all = [early, mid, late, endgame];
/// 레벨로 구간 찾기
static LevelTierSettings forLevel(int level) {
for (final tier in all) {
if (level >= tier.minLevel && level <= tier.maxLevel) {
return tier;
}
}
return endgame;
}
}
/// 플레이어 스탯 스케일링 (player stat scaling)
class PlayerScaling {
PlayerScaling._();
/// 레벨당 HP 증가량
static const int hpPerLevel = 10;
/// 레벨당 MP 증가량
static const int mpPerLevel = 5;
/// 레벨업 시 HP/MP 계산
static ({int hpMax, int mpMax}) calculateResources({
required int level,
required int baseHp,
required int baseMp,
required int conBonus,
required int intBonus,
}) {
final hpMax = baseHp + (level - 1) * hpPerLevel + conBonus * 5;
final mpMax = baseMp + (level - 1) * mpPerLevel + intBonus * 3;
return (hpMax: hpMax, mpMax: mpMax);
}
}

View File

@@ -3,7 +3,12 @@ import 'package:flutter/material.dart';
import 'package:askiineverdie/l10n/app_localizations.dart';
class FrontScreen extends StatelessWidget {
const FrontScreen({super.key, this.onNewCharacter, this.onLoadSave});
const FrontScreen({
super.key,
this.onNewCharacter,
this.onLoadSave,
this.onHallOfFame,
});
/// "New character" 버튼 클릭 시 호출
final void Function(BuildContext context)? onNewCharacter;
@@ -11,6 +16,9 @@ class FrontScreen extends StatelessWidget {
/// "Load save" 버튼 클릭 시 호출
final Future<void> Function(BuildContext context)? onLoadSave;
/// "Hall of Fame" 버튼 클릭 시 호출 (Phase 10)
final void Function(BuildContext context)? onHallOfFame;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
@@ -43,6 +51,9 @@ class FrontScreen extends StatelessWidget {
onLoadSave: onLoadSave != null
? () => onLoadSave!(context)
: () => _showPlaceholder(context),
onHallOfFame: onHallOfFame != null
? () => onHallOfFame!(context)
: null,
),
const SizedBox(height: 24),
const _StatusCards(),
@@ -150,10 +161,15 @@ class _HeroHeader extends StatelessWidget {
}
class _ActionRow extends StatelessWidget {
const _ActionRow({required this.onNewCharacter, required this.onLoadSave});
const _ActionRow({
required this.onNewCharacter,
required this.onLoadSave,
this.onHallOfFame,
});
final VoidCallback onNewCharacter;
final VoidCallback onLoadSave;
final VoidCallback? onHallOfFame;
@override
Widget build(BuildContext context) {
@@ -187,6 +203,13 @@ class _ActionRow extends StatelessWidget {
icon: const Icon(Icons.menu_book_outlined),
label: Text(l10n.viewBuildPlan),
),
// Phase 10: 명예의 전당 버튼
if (onHallOfFame != null)
TextButton.icon(
onPressed: onHallOfFame,
icon: const Icon(Icons.emoji_events_outlined),
label: const Text('Hall of Fame'),
),
],
);
}

View File

@@ -1,16 +1,30 @@
import 'package:flutter/material.dart';
import 'package:askiineverdie/data/story_data.dart';
import 'package:askiineverdie/l10n/app_localizations.dart';
import 'package:askiineverdie/src/core/animation/ascii_animation_data.dart';
import 'package:askiineverdie/src/core/animation/ascii_animation_type.dart';
import 'package:askiineverdie/src/core/engine/story_service.dart';
import 'package:askiineverdie/src/core/model/combat_event.dart';
import 'package:askiineverdie/src/core/l10n/game_data_l10n.dart';
import 'package:askiineverdie/src/core/model/game_state.dart';
import 'package:askiineverdie/src/core/storage/theme_preferences.dart';
import 'package:askiineverdie/src/core/model/hall_of_fame.dart';
import 'package:askiineverdie/src/core/notification/notification_service.dart';
import 'package:askiineverdie/src/core/storage/hall_of_fame_storage.dart';
import 'package:askiineverdie/src/core/util/pq_logic.dart' as pq_logic;
import 'package:askiineverdie/src/features/game/game_session_controller.dart';
import 'package:askiineverdie/src/features/hall_of_fame/hall_of_fame_screen.dart';
import 'package:askiineverdie/src/features/game/widgets/cinematic_view.dart';
import 'package:askiineverdie/src/features/game/widgets/combat_log.dart';
import 'package:askiineverdie/src/features/game/widgets/death_overlay.dart';
import 'package:askiineverdie/src/features/game/widgets/hp_mp_bar.dart';
import 'package:askiineverdie/src/features/game/widgets/notification_overlay.dart';
import 'package:askiineverdie/src/features/game/widgets/skill_panel.dart';
import 'package:askiineverdie/src/features/game/widgets/stats_panel.dart';
import 'package:askiineverdie/src/features/game/widgets/task_progress_panel.dart';
/// 게임 진행 화면 (Main.dfm 기반 3패널 레이아웃)
///
/// Phase 7: colorTheme 제거됨, 고정 4색 팔레트 사용
class GamePlayScreen extends StatefulWidget {
const GamePlayScreen({super.key, required this.controller});
@@ -22,60 +36,228 @@ class GamePlayScreen extends StatefulWidget {
class _GamePlayScreenState extends State<GamePlayScreen>
with WidgetsBindingObserver {
AsciiColorTheme _colorTheme = AsciiColorTheme.green;
AsciiAnimationType? _specialAnimation;
// Phase 8: 알림 서비스 (Notification Service)
late final NotificationService _notificationService;
// Phase 9: 스토리 서비스 (Story Service)
late final StoryService _storyService;
StoryAct _lastAct = StoryAct.prologue;
bool _showingCinematic = false;
// Phase 8: 전투 로그 (Combat Log)
final List<CombatLogEntry> _combatLogEntries = [];
String _lastTaskCaption = '';
// 이전 상태 추적 (레벨업/퀘스트/Act 완료 감지용)
int _lastLevel = 0;
int _lastQuestCount = 0;
int _lastPlotStageCount = 0;
void _cycleColorTheme() {
setState(() {
_colorTheme = switch (_colorTheme) {
AsciiColorTheme.green => AsciiColorTheme.amber,
AsciiColorTheme.amber => AsciiColorTheme.white,
AsciiColorTheme.white => AsciiColorTheme.system,
AsciiColorTheme.system => AsciiColorTheme.green,
};
});
// 테마 변경 시 저장
ThemePreferences.saveColorTheme(_colorTheme);
}
Future<void> _loadColorTheme() async {
final theme = await ThemePreferences.loadColorTheme();
if (mounted) {
setState(() {
_colorTheme = theme;
});
}
}
// 전투 이벤트 추적 (마지막 처리된 이벤트 수)
int _lastProcessedEventCount = 0;
void _checkSpecialEvents(GameState state) {
// Phase 8: 태스크 변경 시 로그 추가
final currentCaption = state.progress.currentTask.caption;
if (currentCaption.isNotEmpty && currentCaption != _lastTaskCaption) {
_addCombatLog(currentCaption, CombatLogType.normal);
_lastTaskCaption = currentCaption;
// 새 태스크 시작 시 이벤트 카운터 리셋
_lastProcessedEventCount = 0;
}
// 전투 이벤트 처리 (Combat Events)
_processCombatEvents(state);
// 레벨업 감지
if (state.traits.level > _lastLevel && _lastLevel > 0) {
_specialAnimation = AsciiAnimationType.levelUp;
_notificationService.showLevelUp(state.traits.level);
_addCombatLog('Level Up! Now level ${state.traits.level}', CombatLogType.levelUp);
_resetSpecialAnimationAfterFrame();
// Phase 9: Act 변경 감지 (레벨 기반)
final newAct = getActForLevel(state.traits.level);
if (newAct != _lastAct && !_showingCinematic) {
_lastAct = newAct;
// Phase 10: 엔딩 도달 시 클리어 처리 (시네마틱 대신 클리어 다이얼로그)
// 다음 프레임에서 실행 (리스너 콜백 중 showDialog 문제 방지)
if (newAct == StoryAct.ending && state.traits.level >= 100) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _handleGameClear(state);
});
} else {
// 일반 Act 전환 시 시네마틱 표시
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _showCinematicForAct(newAct);
});
}
}
}
_lastLevel = state.traits.level;
// 퀘스트 완료 감지
if (state.progress.questCount > _lastQuestCount && _lastQuestCount > 0) {
_specialAnimation = AsciiAnimationType.questComplete;
// 완료된 퀘스트 이름 가져오기
final completedQuest = state.progress.questHistory
.where((q) => q.isComplete)
.lastOrNull;
if (completedQuest != null) {
_notificationService.showQuestComplete(completedQuest.caption);
_addCombatLog('Quest Complete: ${completedQuest.caption}', CombatLogType.questComplete);
}
_resetSpecialAnimationAfterFrame();
}
_lastQuestCount = state.progress.questCount;
// Act 완료 감지 (plotStageCount 증가)
// plotStageCount: 1=프롤로그 진행, 2=프롤로그 완료, 3=Act1 완료...
// 완료된 스테이지 인덱스 = plotStageCount - 2 (0=프롤로그, 1=Act1, ...)
if (state.progress.plotStageCount > _lastPlotStageCount &&
_lastPlotStageCount > 0) {
_specialAnimation = AsciiAnimationType.actComplete;
_notificationService.showActComplete(state.progress.plotStageCount - 2);
_resetSpecialAnimationAfterFrame();
}
_lastPlotStageCount = state.progress.plotStageCount;
}
/// Phase 8: 전투 로그 추가 (Add Combat Log Entry)
void _addCombatLog(String message, CombatLogType type) {
_combatLogEntries.add(CombatLogEntry(
message: message,
timestamp: DateTime.now(),
type: type,
));
// 최대 50개 유지
if (_combatLogEntries.length > 50) {
_combatLogEntries.removeAt(0);
}
}
/// 전투 이벤트를 로그로 변환 (Convert Combat Events to Log)
void _processCombatEvents(GameState state) {
final combat = state.progress.currentCombat;
if (combat == null || !combat.isActive) {
_lastProcessedEventCount = 0;
return;
}
final events = combat.recentEvents;
if (events.isEmpty || events.length <= _lastProcessedEventCount) {
return;
}
// 새 이벤트만 처리
final newEvents = events.skip(_lastProcessedEventCount);
for (final event in newEvents) {
final (message, type) = _formatCombatEvent(event);
_addCombatLog(message, type);
}
_lastProcessedEventCount = events.length;
}
/// 전투 이벤트를 메시지와 타입으로 변환
(String, CombatLogType) _formatCombatEvent(CombatEvent event) {
return switch (event.type) {
CombatEventType.playerAttack => event.isCritical
? ('CRITICAL! ${event.damage} damage to ${event.targetName}!', CombatLogType.critical)
: ('You hit ${event.targetName} for ${event.damage} damage', CombatLogType.damage),
CombatEventType.monsterAttack => (
'${event.targetName} hits you for ${event.damage} damage',
CombatLogType.monsterAttack,
),
CombatEventType.playerEvade => (
'You evaded ${event.targetName}\'s attack!',
CombatLogType.evade,
),
CombatEventType.monsterEvade => (
'${event.targetName} evaded your attack!',
CombatLogType.evade,
),
CombatEventType.playerBlock => (
'Blocked! Reduced to ${event.damage} damage',
CombatLogType.block,
),
CombatEventType.playerParry => (
'Parried! Reduced to ${event.damage} damage',
CombatLogType.parry,
),
CombatEventType.playerSkill => event.isCritical
? ('CRITICAL ${event.skillName}! ${event.damage} damage!', CombatLogType.critical)
: ('${event.skillName}: ${event.damage} damage', CombatLogType.spell),
CombatEventType.playerHeal => (
'${event.skillName ?? "Heal"}: +${event.healAmount} HP',
CombatLogType.heal,
),
CombatEventType.playerBuff => (
'${event.skillName} activated!',
CombatLogType.buff,
),
};
}
/// Phase 9: Act 시네마틱 표시 (Show Act Cinematic)
Future<void> _showCinematicForAct(StoryAct act) async {
if (_showingCinematic) return;
_showingCinematic = true;
// 게임 일시 정지
await widget.controller.pause(saveOnStop: false);
if (mounted) {
await showActCinematic(context, act);
}
// 게임 재개
if (mounted) {
await widget.controller.resume();
}
_showingCinematic = false;
}
/// Phase 10: 게임 클리어 처리 (Handle Game Clear)
Future<void> _handleGameClear(GameState state) async {
// 게임 일시 정지
await widget.controller.pause(saveOnStop: true);
// 명예의 전당 엔트리 생성
final entry = HallOfFameEntry.fromGameState(
state: state,
totalDeaths: 0, // TODO: 사망 횟수 추적 구현 시 연결
monstersKilled: 0, // TODO: 처치 수 추적 구현 시 연결
);
// 명예의 전당에 저장
final storage = HallOfFameStorage();
await storage.addEntry(entry);
// 클리어 다이얼로그 표시
if (mounted) {
await showGameClearDialog(
context,
entry: entry,
onNewGame: () {
// 프론트 화면으로 돌아가기
Navigator.of(context).popUntil((route) => route.isFirst);
},
onViewHallOfFame: () {
// 명예의 전당 화면으로 이동
Navigator.of(context).pushReplacement(
MaterialPageRoute<void>(
builder: (context) => const HallOfFameScreen(),
),
);
},
);
}
}
void _resetSpecialAnimationAfterFrame() {
// 다음 프레임에서 리셋 (AsciiAnimationCard가 값을 받은 후)
WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -90,9 +272,10 @@ class _GamePlayScreenState extends State<GamePlayScreen>
@override
void initState() {
super.initState();
_notificationService = NotificationService();
_storyService = StoryService();
widget.controller.addListener(_onControllerChanged);
WidgetsBinding.instance.addObserver(this);
_loadColorTheme();
// 초기 상태 설정
final state = widget.controller.state;
@@ -100,11 +283,14 @@ class _GamePlayScreenState extends State<GamePlayScreen>
_lastLevel = state.traits.level;
_lastQuestCount = state.progress.questCount;
_lastPlotStageCount = state.progress.plotStageCount;
_lastAct = getActForLevel(state.traits.level);
}
}
@override
void dispose() {
_notificationService.dispose();
_storyService.dispose();
WidgetsBinding.instance.removeObserver(this);
widget.controller.removeListener(_onControllerChanged);
super.dispose();
@@ -178,19 +364,21 @@ class _GamePlayScreenState extends State<GamePlayScreen>
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}
return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, result) async {
if (didPop) return;
final shouldPop = await _onPopInvoked();
if (shouldPop && context.mounted) {
await widget.controller.pause(saveOnStop: false);
if (context.mounted) {
Navigator.of(context).pop();
return NotificationOverlay(
notificationService: _notificationService,
child: PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, result) async {
if (didPop) return;
final shouldPop = await _onPopInvoked();
if (shouldPop && context.mounted) {
await widget.controller.pause(saveOnStop: false);
if (context.mounted) {
Navigator.of(context).pop();
}
}
}
},
child: Scaffold(
},
child: Scaffold(
appBar: AppBar(
title: Text(L10n.of(context).progressQuestTitle(state.traits.name)),
actions: [
@@ -214,48 +402,63 @@ class _GamePlayScreenState extends State<GamePlayScreen>
],
],
),
body: Column(
body: Stack(
children: [
// 상단: ASCII 애니메이션 + Task Progress
TaskProgressPanel(
progress: state.progress,
speedMultiplier: widget.controller.loop?.speedMultiplier ?? 1,
onSpeedCycle: () {
widget.controller.loop?.cycleSpeed();
setState(() {});
},
colorTheme: _colorTheme,
onThemeCycle: _cycleColorTheme,
isPaused: !widget.controller.isRunning,
onPauseToggle: () async {
await widget.controller.togglePause();
setState(() {});
},
specialAnimation: _specialAnimation,
weaponName: state.equipment.weapon,
shieldName: state.equipment.shield,
characterLevel: state.traits.level,
monsterLevel: state.progress.currentTask.monsterLevel,
// 메인 게임 UI
Column(
children: [
// 상단: ASCII 애니메이션 + Task Progress (Phase 7: 고정 4색 팔레트)
TaskProgressPanel(
progress: state.progress,
speedMultiplier: widget.controller.loop?.speedMultiplier ?? 1,
onSpeedCycle: () {
widget.controller.loop?.cycleSpeed();
setState(() {});
},
isPaused: !widget.controller.isRunning,
onPauseToggle: () async {
await widget.controller.togglePause();
setState(() {});
},
specialAnimation: _specialAnimation,
weaponName: state.equipment.weapon,
shieldName: state.equipment.shield,
characterLevel: state.traits.level,
monsterLevel: state.progress.currentTask.monsterLevel,
latestCombatEvent: state.progress.currentCombat?.recentEvents.lastOrNull,
),
// 메인 3패널 영역
Expanded(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 좌측 패널: Character Sheet
Expanded(flex: 2, child: _buildCharacterPanel(state)),
// 중앙 패널: Equipment/Inventory
Expanded(flex: 3, child: _buildEquipmentPanel(state)),
// 우측 패널: Plot/Quest
Expanded(flex: 2, child: _buildQuestPanel(state)),
],
),
),
],
),
// 메인 3패널 영역
Expanded(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 좌측 패널: Character Sheet
Expanded(flex: 2, child: _buildCharacterPanel(state)),
// 중앙 패널: Equipment/Inventory
Expanded(flex: 3, child: _buildEquipmentPanel(state)),
// 우측 패널: Plot/Quest
Expanded(flex: 2, child: _buildQuestPanel(state)),
],
// Phase 4: 사망 오버레이 (Death Overlay)
if (state.isDead && state.deathInfo != null)
DeathOverlay(
deathInfo: state.deathInfo!,
traits: state.traits,
onResurrect: () async {
await widget.controller.resurrect();
},
),
),
],
),
),
),
);
}
@@ -274,9 +477,27 @@ class _GamePlayScreenState extends State<GamePlayScreen>
_buildSectionHeader(l10n.traits),
_buildTraitsList(state),
// Stats 목록
// Stats 목록 (Phase 8: 애니메이션 변화 표시)
_buildSectionHeader(l10n.stats),
Expanded(flex: 2, child: _buildStatsList(state)),
Expanded(flex: 2, child: StatsPanel(stats: state.stats)),
// Phase 8: HP/MP 바 (사망 위험 시 깜빡임, 전투 중 몬스터 HP 표시)
// 전투 중에는 전투 스탯의 HP/MP 사용, 비전투 시 기본 스탯 사용
HpMpBar(
hpCurrent: state.progress.currentCombat?.playerStats.hpCurrent ??
state.stats.hp,
hpMax: state.progress.currentCombat?.playerStats.hpMax ??
state.stats.hpMax,
mpCurrent: state.progress.currentCombat?.playerStats.mpCurrent ??
state.stats.mp,
mpMax: state.progress.currentCombat?.playerStats.mpMax ??
state.stats.mpMax,
// 전투 중일 때 몬스터 HP 정보 전달
monsterHpCurrent:
state.progress.currentCombat?.monsterStats.hpCurrent,
monsterHpMax: state.progress.currentCombat?.monsterStats.hpMax,
monsterName: state.progress.currentCombat?.monsterStats.name,
),
// Experience 바
_buildSectionHeader(l10n.experience),
@@ -292,6 +513,10 @@ class _GamePlayScreenState extends State<GamePlayScreen>
// Spell Book
_buildSectionHeader(l10n.spellBook),
Expanded(flex: 2, child: _buildSpellsList(state)),
// Phase 8: 스킬 (Skills with cooldown glow)
_buildSectionHeader('Skills'),
Expanded(flex: 2, child: SkillPanel(skillSystem: state.skillSystem)),
],
),
);
@@ -312,7 +537,7 @@ class _GamePlayScreenState extends State<GamePlayScreen>
// Inventory
_buildPanelHeader(l10n.inventory),
Expanded(flex: 3, child: _buildInventoryList(state)),
Expanded(flex: 2, child: _buildInventoryList(state)),
// Encumbrance 바
_buildSectionHeader(l10n.encumbrance),
@@ -321,6 +546,10 @@ class _GamePlayScreenState extends State<GamePlayScreen>
state.progress.encumbrance.max,
Colors.orange,
),
// Phase 8: 전투 로그 (Combat Log)
_buildPanelHeader(l10n.combatLog),
Expanded(flex: 2, child: CombatLog(entries: _combatLogEntries)),
],
),
);
@@ -451,40 +680,6 @@ class _GamePlayScreenState extends State<GamePlayScreen>
);
}
Widget _buildStatsList(GameState state) {
final l10n = L10n.of(context);
final stats = [
(l10n.statStr, state.stats.str),
(l10n.statCon, state.stats.con),
(l10n.statDex, state.stats.dex),
(l10n.statInt, state.stats.intelligence),
(l10n.statWis, state.stats.wis),
(l10n.statCha, state.stats.cha),
(l10n.statHpMax, state.stats.hpMax),
(l10n.statMpMax, state.stats.mpMax),
];
return ListView.builder(
itemCount: stats.length,
padding: const EdgeInsets.symmetric(horizontal: 8),
itemBuilder: (context, index) {
final stat = stats[index];
return Row(
children: [
SizedBox(
width: 50,
child: Text(stat.$1, style: const TextStyle(fontSize: 11)),
),
Text(
'${stat.$2}',
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.bold),
),
],
);
},
);
}
Widget _buildSpellsList(GameState state) {
if (state.spellBook.spells.isEmpty) {
return Center(

View File

@@ -9,6 +9,8 @@ import 'package:askiineverdie/src/core/animation/battle_composer.dart';
import 'package:askiineverdie/src/core/animation/character_frames.dart';
import 'package:askiineverdie/src/core/animation/monster_size.dart';
import 'package:askiineverdie/src/core/animation/weapon_category.dart';
import 'package:askiineverdie/src/core/constants/ascii_colors.dart';
import 'package:askiineverdie/src/core/model/combat_event.dart';
import 'package:askiineverdie/src/core/model/game_state.dart';
/// ASCII 애니메이션 카드 위젯
@@ -29,6 +31,7 @@ class AsciiAnimationCard extends StatefulWidget {
this.characterLevel,
this.monsterLevel,
this.isPaused = false,
this.latestCombatEvent,
});
final TaskType taskType;
@@ -56,6 +59,9 @@ class AsciiAnimationCard extends StatefulWidget {
/// 몬스터 레벨 (몬스터 크기 결정용)
final int? monsterLevel;
/// 최근 전투 이벤트 (애니메이션 동기화용)
final CombatEvent? latestCombatEvent;
@override
State<AsciiAnimationCard> createState() => _AsciiAnimationCardState();
}
@@ -89,6 +95,10 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
int _phaseIndex = 0;
int _phaseFrameCount = 0;
// 전투 이벤트 동기화용 (Phase 5)
int? _lastEventTimestamp;
bool _showCriticalEffect = false;
@override
void initState() {
super.initState();
@@ -123,6 +133,12 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
return;
}
// 전투 이벤트 동기화 (Phase 5)
if (widget.latestCombatEvent != null &&
widget.latestCombatEvent!.timestamp != _lastEventTimestamp) {
_handleCombatEvent(widget.latestCombatEvent!);
}
if (oldWidget.taskType != widget.taskType ||
oldWidget.monsterBaseName != widget.monsterBaseName ||
oldWidget.weaponName != widget.weaponName ||
@@ -132,6 +148,45 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
}
}
/// 전투 이벤트에 따라 애니메이션 페이즈 강제 전환 (Phase 5)
void _handleCombatEvent(CombatEvent event) {
_lastEventTimestamp = event.timestamp;
// 전투 모드가 아니면 무시
if (!_isBattleMode) return;
// 이벤트 타입에 따라 페이즈 강제 전환
final (targetPhase, isCritical) = switch (event.type) {
// 플레이어 공격 → attack 페이즈
CombatEventType.playerAttack => (BattlePhase.attack, event.isCritical),
CombatEventType.playerSkill => (BattlePhase.attack, event.isCritical),
// 몬스터 공격/플레이어 피격 → hit 페이즈
CombatEventType.monsterAttack => (BattlePhase.hit, false),
CombatEventType.playerBlock => (BattlePhase.hit, false),
CombatEventType.playerParry => (BattlePhase.hit, false),
// 회피 → recover 페이즈 (빠른 회피 동작)
CombatEventType.playerEvade => (BattlePhase.recover, false),
CombatEventType.monsterEvade => (BattlePhase.idle, false),
// 회복/버프 → idle 페이즈 유지
CombatEventType.playerHeal => (BattlePhase.idle, false),
CombatEventType.playerBuff => (BattlePhase.idle, false),
};
setState(() {
_battlePhase = targetPhase;
_battleSubFrame = 0;
_phaseFrameCount = 0;
_showCriticalEffect = isCritical;
// 페이즈 인덱스 동기화
_phaseIndex = _battlePhaseSequence.indexWhere((p) => p.$1 == targetPhase);
if (_phaseIndex < 0) _phaseIndex = 0;
});
}
/// 현재 상태를 유지하면서 타이머만 재시작
void _restartTimer() {
_timer?.cancel();
@@ -285,6 +340,8 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
_phaseIndex = (_phaseIndex + 1) % _battlePhaseSequence.length;
_phaseFrameCount = 0;
_battleSubFrame = 0;
// 크리티컬 이펙트 리셋 (페이즈 전환 시)
_showCriticalEffect = false;
} else {
_battleSubFrame++;
}
@@ -336,31 +393,26 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
return TextSpan(children: spans);
}
/// 이펙트 문자별 색상 반환
/// 이펙트 문자별 색상 반환 (Phase 7: 4색 팔레트)
Color _getEffectColor(String char) {
return switch (char) {
'*' => Colors.orange, // 히트/폭발
'!' => Colors.yellow, // 강조
'=' || '>' || '<' => Colors.cyan, // 슬래시/찌르기
'~' => Colors.purple, // 물결/마법
_ => Colors.white,
'*' => AsciiColors.negative, // 히트/폭발 (마젠타)
'!' => AsciiColors.positive, // 강조 (시안)
'=' || '>' || '<' => AsciiColors.positive, // 슬래시/찌르기 (시안)
'~' => AsciiColors.negative, // 물결/디버프 (마젠타)
'+' => AsciiColors.positive, // 회복/버프 (시안)
_ => AsciiColors.object, // 오브젝트 (흰색)
};
}
@override
Widget build(BuildContext context) {
final brightness = Theme.of(context).brightness;
final colors = getThemeColors(widget.colorTheme, brightness);
// 특수 애니메이션 중이면 특별한 배경색 적용
final isSpecial = _currentSpecialAnimation != null;
final bgColor = isSpecial
? colors.backgroundColor.withValues(alpha: 0.95)
: colors.backgroundColor;
// Phase 7: 고정 4색 팔레트 사용 (colorTheme 무시)
const bgColor = AsciiColors.background;
const textColor = AsciiColors.object;
// 프레임 텍스트 결정
String frameText;
Color textColor = colors.textColor;
if (_isBattleMode && _battleComposer != null) {
// 새 배틀 시스템 사용 (배경 포함)
@@ -380,14 +432,23 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
frameText = _animationData.frames[frameIndex];
}
// 테두리 효과 결정 (특수 애니메이션 또는 크리티컬 히트)
final isSpecial = _currentSpecialAnimation != null;
Border? borderEffect;
if (_showCriticalEffect) {
// 크리티컬 히트: 노란색 테두리 (Phase 5)
borderEffect = Border.all(color: Colors.yellow.withValues(alpha: 0.8), width: 2);
} else if (isSpecial) {
// 특수 애니메이션: 시안 테두리
borderEffect = Border.all(color: AsciiColors.positive.withValues(alpha: 0.5));
}
return Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: bgColor,
borderRadius: BorderRadius.circular(4),
border: isSpecial
? Border.all(color: colors.textColor.withValues(alpha: 0.5))
: null,
border: borderEffect,
),
child: _isBattleMode
? LayoutBuilder(

View File

@@ -0,0 +1,284 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:askiineverdie/data/story_data.dart';
/// 시네마틱 뷰 위젯 (Phase 9: Cinematic UI)
///
/// Act 전환 시 풀스크린 시네마틱 표시
class CinematicView extends StatefulWidget {
const CinematicView({
super.key,
required this.steps,
required this.onComplete,
this.canSkip = true,
});
final List<CinematicStep> steps;
final VoidCallback onComplete;
final bool canSkip;
@override
State<CinematicView> createState() => _CinematicViewState();
}
class _CinematicViewState extends State<CinematicView>
with SingleTickerProviderStateMixin {
int _currentStep = 0;
Timer? _autoAdvanceTimer;
late AnimationController _fadeController;
late Animation<double> _fadeAnimation;
@override
void initState() {
super.initState();
_fadeController = AnimationController(
duration: const Duration(milliseconds: 500),
vsync: this,
);
_fadeAnimation = CurvedAnimation(
parent: _fadeController,
curve: Curves.easeInOut,
);
_fadeController.forward();
_scheduleAutoAdvance();
}
void _scheduleAutoAdvance() {
_autoAdvanceTimer?.cancel();
if (_currentStep < widget.steps.length) {
final step = widget.steps[_currentStep];
_autoAdvanceTimer = Timer(
Duration(milliseconds: step.durationMs),
_advanceStep,
);
}
}
void _advanceStep() {
if (_currentStep >= widget.steps.length - 1) {
_complete();
return;
}
_fadeController.reverse().then((_) {
if (mounted) {
setState(() {
_currentStep++;
});
_fadeController.forward();
_scheduleAutoAdvance();
}
});
}
void _complete() {
_autoAdvanceTimer?.cancel();
widget.onComplete();
}
void _skip() {
if (widget.canSkip) {
_complete();
}
}
@override
void dispose() {
_autoAdvanceTimer?.cancel();
_fadeController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (widget.steps.isEmpty) {
WidgetsBinding.instance.addPostFrameCallback((_) {
widget.onComplete();
});
return const SizedBox.shrink();
}
final step = widget.steps[_currentStep];
return GestureDetector(
onTap: _advanceStep,
child: Material(
color: Colors.black,
child: SafeArea(
child: Stack(
children: [
// 메인 콘텐츠
Center(
child: FadeTransition(
opacity: _fadeAnimation,
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// ASCII 아트
if (step.asciiArt != null) ...[
_AsciiArtDisplay(asciiArt: step.asciiArt!),
const SizedBox(height: 24),
],
// 텍스트
Text(
step.text,
style: const TextStyle(
color: Colors.white,
fontSize: 18,
fontFamily: 'monospace',
height: 1.5,
),
textAlign: TextAlign.center,
),
],
),
),
),
),
// 진행 표시 (Progress Indicator)
Positioned(
bottom: 40,
left: 0,
right: 0,
child: _ProgressDots(
total: widget.steps.length,
current: _currentStep,
),
),
// 스킵 버튼
if (widget.canSkip)
Positioned(
top: 16,
right: 16,
child: TextButton(
onPressed: _skip,
child: const Text(
'SKIP',
style: TextStyle(
color: Colors.white54,
fontSize: 14,
),
),
),
),
// 탭 힌트
Positioned(
bottom: 16,
left: 0,
right: 0,
child: Text(
'Tap to continue',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.3),
fontSize: 12,
),
textAlign: TextAlign.center,
),
),
],
),
),
),
);
}
}
/// ASCII 아트 표시 위젯
class _AsciiArtDisplay extends StatelessWidget {
const _AsciiArtDisplay({required this.asciiArt});
final String asciiArt;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border.all(color: Colors.cyan.withValues(alpha: 0.5)),
borderRadius: BorderRadius.circular(8),
),
child: Text(
asciiArt,
style: const TextStyle(
color: Colors.cyan,
fontSize: 14,
fontFamily: 'monospace',
height: 1.2,
),
textAlign: TextAlign.center,
),
);
}
}
/// 진행 도트 표시 위젯
class _ProgressDots extends StatelessWidget {
const _ProgressDots({required this.total, required this.current});
final int total;
final int current;
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(total, (index) {
final isActive = index == current;
final isPast = index < current;
return Container(
width: isActive ? 12 : 8,
height: isActive ? 12 : 8,
margin: const EdgeInsets.symmetric(horizontal: 4),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isActive
? Colors.cyan
: isPast
? Colors.cyan.withValues(alpha: 0.5)
: Colors.white.withValues(alpha: 0.2),
),
);
}),
);
}
}
/// 시네마틱 표시 다이얼로그 함수 (Show Cinematic Dialog)
Future<void> showCinematic(
BuildContext context, {
required List<CinematicStep> steps,
bool canSkip = true,
}) async {
if (steps.isEmpty) return;
return showDialog<void>(
context: context,
barrierDismissible: false,
barrierColor: Colors.black,
builder: (context) => CinematicView(
steps: steps,
canSkip: canSkip,
onComplete: () => Navigator.of(context).pop(),
),
);
}
/// Act 시네마틱 표시 함수 (Show Act Cinematic)
Future<void> showActCinematic(BuildContext context, StoryAct act) async {
final steps = cinematicData[act];
if (steps == null || steps.isEmpty) return;
await showCinematic(context, steps: steps);
}

View File

@@ -0,0 +1,162 @@
import 'package:flutter/material.dart';
/// 전투 로그 엔트리 (Combat Log Entry)
class CombatLogEntry {
const CombatLogEntry({
required this.message,
required this.timestamp,
this.type = CombatLogType.normal,
});
final String message;
final DateTime timestamp;
final CombatLogType type;
}
/// 로그 타입에 따른 스타일 구분
enum CombatLogType {
normal, // 일반 메시지
damage, // 피해 입힘
heal, // 회복
levelUp, // 레벨업
questComplete, // 퀘스트 완료
loot, // 전리품 획득
spell, // 주문 습득
critical, // 크리티컬 히트
evade, // 회피
block, // 방패 방어
parry, // 무기 쳐내기
monsterAttack, // 몬스터 공격
buff, // 버프 활성화
}
/// 전투 로그 위젯 (Phase 8: 실시간 전투 이벤트 표시)
///
/// 최근 전투 이벤트를 스크롤 가능한 리스트로 표시
class CombatLog extends StatefulWidget {
const CombatLog({super.key, required this.entries, this.maxEntries = 50});
final List<CombatLogEntry> entries;
final int maxEntries;
@override
State<CombatLog> createState() => _CombatLogState();
}
class _CombatLogState extends State<CombatLog> {
final ScrollController _scrollController = ScrollController();
int _previousLength = 0;
@override
void didUpdateWidget(CombatLog oldWidget) {
super.didUpdateWidget(oldWidget);
// 새 로그 추가 시 자동 스크롤
if (widget.entries.length > _previousLength) {
_scrollToBottom();
}
_previousLength = widget.entries.length;
}
void _scrollToBottom() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollController.hasClients) {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
);
}
});
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ListView.builder(
controller: _scrollController,
itemCount: widget.entries.length,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
itemBuilder: (context, index) {
final entry = widget.entries[index];
return _LogEntryTile(entry: entry);
},
);
}
}
/// 개별 로그 엔트리 타일
class _LogEntryTile extends StatelessWidget {
const _LogEntryTile({required this.entry});
final CombatLogEntry entry;
@override
Widget build(BuildContext context) {
final (color, icon) = _getStyleForType(entry.type);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 1),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 타임스탬프(timestamp)
Text(
_formatTime(entry.timestamp),
style: TextStyle(
fontSize: 10,
color: Theme.of(context).colorScheme.outline,
fontFamily: 'monospace',
),
),
const SizedBox(width: 4),
// 아이콘
if (icon != null)
Padding(
padding: const EdgeInsets.only(right: 4),
child: Icon(icon, size: 12, color: color),
),
// 메시지
Expanded(
child: Text(
entry.message,
style: TextStyle(
fontSize: 11,
color: color ?? Theme.of(context).colorScheme.onSurface,
),
),
),
],
),
);
}
String _formatTime(DateTime time) {
return '${time.hour.toString().padLeft(2, '0')}:'
'${time.minute.toString().padLeft(2, '0')}:'
'${time.second.toString().padLeft(2, '0')}';
}
(Color?, IconData?) _getStyleForType(CombatLogType type) {
return switch (type) {
CombatLogType.normal => (null, null),
CombatLogType.damage => (Colors.red.shade300, Icons.local_fire_department),
CombatLogType.heal => (Colors.green.shade300, Icons.healing),
CombatLogType.levelUp => (Colors.amber, Icons.arrow_upward),
CombatLogType.questComplete => (Colors.blue.shade300, Icons.check_circle),
CombatLogType.loot => (Colors.orange.shade300, Icons.inventory_2),
CombatLogType.spell => (Colors.purple.shade300, Icons.auto_fix_high),
CombatLogType.critical => (Colors.yellow.shade300, Icons.flash_on),
CombatLogType.evade => (Colors.cyan.shade300, Icons.directions_run),
CombatLogType.block => (Colors.blueGrey.shade300, Icons.shield),
CombatLogType.parry => (Colors.teal.shade300, Icons.sports_kabaddi),
CombatLogType.monsterAttack => (Colors.deepOrange.shade300, Icons.dangerous),
CombatLogType.buff => (Colors.lightBlue.shade300, Icons.trending_up),
};
}
}

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:askiineverdie/src/core/model/combat_event.dart';
import 'package:askiineverdie/src/core/model/game_state.dart';
/// 사망 오버레이 위젯 (Phase 4)
@@ -70,6 +71,15 @@ class DeathOverlay extends StatelessWidget {
// 상실 정보
_buildLossInfo(context),
// 전투 로그 (있는 경우만 표시)
if (deathInfo.lastCombatEvents.isNotEmpty) ...[
const SizedBox(height: 16),
Divider(color: colorScheme.outlineVariant),
const SizedBox(height: 8),
_buildCombatLog(context),
],
const SizedBox(height: 24),
// 부활 버튼
@@ -169,16 +179,65 @@ class DeathOverlay extends StatelessWidget {
}
Widget _buildLossInfo(BuildContext context) {
final theme = Theme.of(context);
final hasLostItem = deathInfo.lostItemName != null;
return Column(
children: [
_buildInfoRow(
context,
icon: Icons.shield_outlined,
label: 'Equipment Lost',
value: '${deathInfo.lostEquipmentCount} items',
isNegative: true,
),
const SizedBox(height: 8),
// 제물로 바친 아이템 표시
if (hasLostItem) ...[
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: theme.colorScheme.errorContainer.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: theme.colorScheme.error.withValues(alpha: 0.3),
),
),
child: Row(
children: [
Icon(
Icons.local_fire_department,
size: 20,
color: theme.colorScheme.error,
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Sacrificed to Resurrect',
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 2),
Text(
deathInfo.lostItemName!,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.error,
),
),
],
),
),
],
),
),
const SizedBox(height: 12),
] else ...[
_buildInfoRow(
context,
icon: Icons.check_circle_outline,
label: 'Equipment',
value: 'No sacrifice needed',
isNegative: false,
),
const SizedBox(height: 8),
],
_buildInfoRow(
context,
icon: Icons.monetization_on_outlined,
@@ -253,4 +312,118 @@ class DeathOverlay extends StatelessWidget {
),
);
}
/// 사망 직전 전투 로그 표시
Widget _buildCombatLog(BuildContext context) {
final theme = Theme.of(context);
final events = deathInfo.lastCombatEvents;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Combat Log',
style: theme.textTheme.labelMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Container(
constraints: const BoxConstraints(maxHeight: 120),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: ListView.builder(
shrinkWrap: true,
padding: const EdgeInsets.all(8),
itemCount: events.length,
itemBuilder: (context, index) {
final event = events[index];
return _buildCombatEventTile(context, event);
},
),
),
],
);
}
/// 개별 전투 이벤트 타일
Widget _buildCombatEventTile(BuildContext context, CombatEvent event) {
final (icon, color, message) = _formatCombatEvent(event);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
children: [
Icon(icon, size: 12, color: color),
const SizedBox(width: 6),
Expanded(
child: Text(
message,
style: TextStyle(
fontSize: 11,
color: color,
fontFamily: 'monospace',
),
),
),
],
),
);
}
/// 전투 이벤트를 아이콘, 색상, 메시지로 포맷
(IconData, Color, String) _formatCombatEvent(CombatEvent event) {
return switch (event.type) {
CombatEventType.playerAttack => (
event.isCritical ? Icons.flash_on : Icons.local_fire_department,
event.isCritical ? Colors.yellow.shade300 : Colors.green.shade300,
event.isCritical
? 'CRITICAL! ${event.damage} damage to ${event.targetName}'
: 'Hit ${event.targetName} for ${event.damage} damage',
),
CombatEventType.monsterAttack => (
Icons.dangerous,
Colors.red.shade300,
'${event.targetName} hits you for ${event.damage} damage',
),
CombatEventType.playerEvade => (
Icons.directions_run,
Colors.cyan.shade300,
'Evaded attack from ${event.targetName}',
),
CombatEventType.monsterEvade => (
Icons.directions_run,
Colors.orange.shade300,
'${event.targetName} evaded your attack',
),
CombatEventType.playerBlock => (
Icons.shield,
Colors.blueGrey.shade300,
'Blocked ${event.targetName}\'s attack (${event.damage} reduced)',
),
CombatEventType.playerParry => (
Icons.sports_kabaddi,
Colors.teal.shade300,
'Parried ${event.targetName}\'s attack (${event.damage} reduced)',
),
CombatEventType.playerSkill => (
Icons.auto_fix_high,
Colors.purple.shade300,
'${event.skillName} deals ${event.damage} damage',
),
CombatEventType.playerHeal => (
Icons.healing,
Colors.green.shade300,
'Healed for ${event.healAmount} HP',
),
CombatEventType.playerBuff => (
Icons.trending_up,
Colors.lightBlue.shade300,
'${event.skillName} activated',
),
};
}
}

View File

@@ -0,0 +1,415 @@
import 'package:flutter/material.dart';
/// HP/MP 바 위젯 (Phase 8: 변화 시 시각 효과)
///
/// - HP가 20% 미만일 때 빨간색 깜빡임
/// - HP/MP 변화 시 색상 플래시 + 변화량 표시
/// - 전투 중 몬스터 HP 바 표시
class HpMpBar extends StatefulWidget {
const HpMpBar({
super.key,
required this.hpCurrent,
required this.hpMax,
required this.mpCurrent,
required this.mpMax,
this.monsterHpCurrent,
this.monsterHpMax,
this.monsterName,
});
final int hpCurrent;
final int hpMax;
final int mpCurrent;
final int mpMax;
/// 전투 중 몬스터 HP (null이면 비전투)
final int? monsterHpCurrent;
final int? monsterHpMax;
final String? monsterName;
@override
State<HpMpBar> createState() => _HpMpBarState();
}
class _HpMpBarState extends State<HpMpBar> with TickerProviderStateMixin {
late AnimationController _blinkController;
late Animation<double> _blinkAnimation;
// HP/MP 변화 애니메이션
late AnimationController _hpFlashController;
late AnimationController _mpFlashController;
late Animation<double> _hpFlashAnimation;
late Animation<double> _mpFlashAnimation;
// 변화량 표시용
int _hpChange = 0;
int _mpChange = 0;
bool _hpDamage = false;
bool _mpDamage = false;
// 몬스터 HP 변화 애니메이션
late AnimationController _monsterFlashController;
late Animation<double> _monsterFlashAnimation;
int _monsterHpChange = 0;
@override
void initState() {
super.initState();
// 위험 깜빡임
_blinkController = AnimationController(
duration: const Duration(milliseconds: 500),
vsync: this,
);
_blinkAnimation = Tween<double>(begin: 1.0, end: 0.3).animate(
CurvedAnimation(parent: _blinkController, curve: Curves.easeInOut),
);
// HP 플래시
_hpFlashController = AnimationController(
duration: const Duration(milliseconds: 400),
vsync: this,
);
_hpFlashAnimation = Tween<double>(begin: 1.0, end: 0.0).animate(
CurvedAnimation(parent: _hpFlashController, curve: Curves.easeOut),
);
// MP 플래시
_mpFlashController = AnimationController(
duration: const Duration(milliseconds: 400),
vsync: this,
);
_mpFlashAnimation = Tween<double>(begin: 1.0, end: 0.0).animate(
CurvedAnimation(parent: _mpFlashController, curve: Curves.easeOut),
);
// 몬스터 HP 플래시
_monsterFlashController = AnimationController(
duration: const Duration(milliseconds: 400),
vsync: this,
);
_monsterFlashAnimation = Tween<double>(begin: 1.0, end: 0.0).animate(
CurvedAnimation(parent: _monsterFlashController, curve: Curves.easeOut),
);
_updateBlinkState();
}
@override
void didUpdateWidget(HpMpBar oldWidget) {
super.didUpdateWidget(oldWidget);
// HP 변화 감지
if (oldWidget.hpCurrent != widget.hpCurrent) {
_hpChange = widget.hpCurrent - oldWidget.hpCurrent;
_hpDamage = _hpChange < 0;
_hpFlashController.forward(from: 0.0);
}
// MP 변화 감지
if (oldWidget.mpCurrent != widget.mpCurrent) {
_mpChange = widget.mpCurrent - oldWidget.mpCurrent;
_mpDamage = _mpChange < 0;
_mpFlashController.forward(from: 0.0);
}
// 몬스터 HP 변화 감지
if (oldWidget.monsterHpCurrent != widget.monsterHpCurrent &&
widget.monsterHpCurrent != null &&
oldWidget.monsterHpCurrent != null) {
_monsterHpChange = widget.monsterHpCurrent! - oldWidget.monsterHpCurrent!;
_monsterFlashController.forward(from: 0.0);
}
_updateBlinkState();
}
void _updateBlinkState() {
final hpRatio = widget.hpMax > 0 ? widget.hpCurrent / widget.hpMax : 1.0;
if (hpRatio < 0.2 && hpRatio > 0) {
if (!_blinkController.isAnimating) {
_blinkController.repeat(reverse: true);
}
} else {
_blinkController.stop();
_blinkController.reset();
}
}
@override
void dispose() {
_blinkController.dispose();
_hpFlashController.dispose();
_mpFlashController.dispose();
_monsterFlashController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final hpRatio = widget.hpMax > 0 ? widget.hpCurrent / widget.hpMax : 0.0;
final mpRatio = widget.mpMax > 0 ? widget.mpCurrent / widget.mpMax : 0.0;
final hasMonster = widget.monsterHpCurrent != null &&
widget.monsterHpMax != null &&
widget.monsterHpMax! > 0;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// HP 바 (플래시 효과 포함)
_buildAnimatedBar(
label: 'HP',
current: widget.hpCurrent,
max: widget.hpMax,
ratio: hpRatio,
color: Colors.red,
isLow: hpRatio < 0.2 && hpRatio > 0,
flashController: _hpFlashAnimation,
change: _hpChange,
isDamage: _hpDamage,
),
const SizedBox(height: 4),
// MP 바 (플래시 효과 포함)
_buildAnimatedBar(
label: 'MP',
current: widget.mpCurrent,
max: widget.mpMax,
ratio: mpRatio,
color: Colors.blue,
isLow: false,
flashController: _mpFlashAnimation,
change: _mpChange,
isDamage: _mpDamage,
),
// 몬스터 HP 바 (전투 중일 때만)
if (hasMonster) ...[
const SizedBox(height: 8),
_buildMonsterBar(),
],
],
),
);
}
Widget _buildAnimatedBar({
required String label,
required int current,
required int max,
required double ratio,
required Color color,
required bool isLow,
required Animation<double> flashController,
required int change,
required bool isDamage,
}) {
return AnimatedBuilder(
animation: Listenable.merge([_blinkAnimation, flashController]),
builder: (context, child) {
// 플래시 색상 (데미지=빨강, 회복=녹색)
final flashColor = isDamage
? Colors.red.withValues(alpha: flashController.value * 0.4)
: Colors.green.withValues(alpha: flashController.value * 0.4);
// 위험 깜빡임 배경
final lowBgColor = isLow
? Colors.red.withValues(alpha: (1 - _blinkAnimation.value) * 0.3)
: Colors.transparent;
return Container(
decoration: BoxDecoration(
color: flashController.value > 0.1 ? flashColor : lowBgColor,
borderRadius: BorderRadius.circular(4),
),
child: Stack(
children: [
_buildBar(label: label, current: current, max: max, ratio: ratio, color: color),
// 플로팅 변화량 텍스트 (위로 떠오르며 사라짐)
if (change != 0 && flashController.value > 0.05)
Positioned(
right: 70,
top: 0,
bottom: 0,
child: Transform.translate(
// 위로 떠오르는 애니메이션 (최대 15픽셀 위로)
offset: Offset(0, -15 * (1 - flashController.value)),
child: Opacity(
opacity: flashController.value,
child: Text(
change > 0 ? '+$change' : '$change',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: isDamage ? Colors.red : Colors.green,
shadows: const [
Shadow(color: Colors.black, blurRadius: 3),
Shadow(color: Colors.black, blurRadius: 6),
],
),
),
),
),
),
],
),
);
},
);
}
Widget _buildBar({
required String label,
required int current,
required int max,
required double ratio,
required Color color,
}) {
return Row(
children: [
SizedBox(
width: 24,
child: Text(
label,
style: const TextStyle(fontSize: 10, fontWeight: FontWeight.bold),
),
),
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(2),
child: LinearProgressIndicator(
value: ratio.clamp(0.0, 1.0),
backgroundColor: color.withValues(alpha: 0.2),
valueColor: AlwaysStoppedAnimation<Color>(color),
minHeight: 10,
),
),
),
const SizedBox(width: 4),
SizedBox(
width: 60,
child: Text(
'$current/$max',
style: const TextStyle(fontSize: 9),
textAlign: TextAlign.right,
),
),
],
);
}
/// 몬스터 HP 바
Widget _buildMonsterBar() {
final current = widget.monsterHpCurrent!;
final max = widget.monsterHpMax!;
final ratio = max > 0 ? current / max : 0.0;
final name = widget.monsterName ?? 'Enemy';
return AnimatedBuilder(
animation: _monsterFlashAnimation,
builder: (context, child) {
// 데미지 플래시 (몬스터는 항상 데미지를 받음)
final flashColor = Colors.yellow.withValues(
alpha: _monsterFlashAnimation.value * 0.3,
);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
decoration: BoxDecoration(
color: _monsterFlashAnimation.value > 0.1
? flashColor
: Colors.orange.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(4),
border: Border.all(color: Colors.orange.withValues(alpha: 0.3)),
),
child: Stack(
clipBehavior: Clip.none,
children: [
Row(
children: [
// 몬스터 아이콘
const Icon(Icons.pest_control, size: 14, color: Colors.orange),
const SizedBox(width: 4),
// 이름 (Flexible로 공간 부족 시 축소)
Flexible(
flex: 0,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 50),
child: Text(
name.length > 8 ? '${name.substring(0, 6)}...' : name,
style: const TextStyle(
fontSize: 9,
fontWeight: FontWeight.bold,
color: Colors.orange,
),
overflow: TextOverflow.ellipsis,
),
),
),
const SizedBox(width: 4),
// HP 바
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(2),
child: LinearProgressIndicator(
value: ratio.clamp(0.0, 1.0),
backgroundColor: Colors.orange.withValues(alpha: 0.2),
valueColor:
const AlwaysStoppedAnimation<Color>(Colors.orange),
minHeight: 8,
),
),
),
const SizedBox(width: 4),
// HP 숫자
Text(
'$current/$max',
style: const TextStyle(fontSize: 8, color: Colors.orange),
textAlign: TextAlign.right,
),
],
),
// 플로팅 데미지 텍스트
if (_monsterHpChange != 0 && _monsterFlashAnimation.value > 0.05)
Positioned(
right: 60,
top: -5,
child: Transform.translate(
offset: Offset(0, -12 * (1 - _monsterFlashAnimation.value)),
child: Opacity(
opacity: _monsterFlashAnimation.value,
child: Text(
_monsterHpChange > 0
? '+$_monsterHpChange'
: '$_monsterHpChange',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
color: _monsterHpChange < 0
? Colors.yellow
: Colors.green,
shadows: const [
Shadow(color: Colors.black, blurRadius: 3),
Shadow(color: Colors.black, blurRadius: 6),
],
),
),
),
),
),
],
),
);
},
);
}
}

View File

@@ -0,0 +1,218 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:askiineverdie/src/core/notification/notification_service.dart';
/// 알림 오버레이 위젯 (Phase 8: 팝업/토스트 알림)
///
/// 화면 상단에 알림을 슬라이드 인/아웃 애니메이션으로 표시
class NotificationOverlay extends StatefulWidget {
const NotificationOverlay({
super.key,
required this.notificationService,
required this.child,
});
final NotificationService notificationService;
final Widget child;
@override
State<NotificationOverlay> createState() => _NotificationOverlayState();
}
class _NotificationOverlayState extends State<NotificationOverlay>
with SingleTickerProviderStateMixin {
GameNotification? _currentNotification;
late AnimationController _animationController;
late Animation<Offset> _slideAnimation;
late Animation<double> _fadeAnimation;
StreamSubscription<GameNotification>? _notificationSub;
StreamSubscription<void>? _dismissSub;
@override
void initState() {
super.initState();
_animationController = AnimationController(
duration: const Duration(milliseconds: 300),
vsync: this,
);
_slideAnimation = Tween<Offset>(
begin: const Offset(0, -1),
end: Offset.zero,
).animate(CurvedAnimation(
parent: _animationController,
curve: Curves.easeOutBack,
));
_fadeAnimation = Tween<double>(begin: 0, end: 1).animate(
CurvedAnimation(parent: _animationController, curve: Curves.easeIn),
);
_notificationSub =
widget.notificationService.notifications.listen(_onNotification);
_dismissSub = widget.notificationService.dismissals.listen(_onDismiss);
}
void _onNotification(GameNotification notification) {
setState(() => _currentNotification = notification);
_animationController.forward();
}
void _onDismiss(void _) {
_animationController.reverse().then((_) {
if (mounted) {
setState(() => _currentNotification = null);
}
});
}
@override
void dispose() {
_notificationSub?.cancel();
_dismissSub?.cancel();
_animationController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Stack(
children: [
widget.child,
if (_currentNotification != null)
Positioned(
top: MediaQuery.of(context).padding.top + 16,
left: 16,
right: 16,
child: SlideTransition(
position: _slideAnimation,
child: FadeTransition(
opacity: _fadeAnimation,
child: _NotificationCard(
notification: _currentNotification!,
onDismiss: widget.notificationService.dismiss,
),
),
),
),
],
);
}
}
/// 알림 카드 위젯
class _NotificationCard extends StatelessWidget {
const _NotificationCard({
required this.notification,
required this.onDismiss,
});
final GameNotification notification;
final VoidCallback onDismiss;
@override
Widget build(BuildContext context) {
final (bgColor, icon, iconColor) = _getStyleForType(notification.type);
return Material(
elevation: 8,
borderRadius: BorderRadius.circular(12),
color: bgColor,
child: InkWell(
onTap: onDismiss,
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
// 아이콘
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: iconColor.withValues(alpha: 0.2),
shape: BoxShape.circle,
),
child: Icon(icon, color: iconColor, size: 24),
),
const SizedBox(width: 12),
// 텍스트
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
notification.title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
if (notification.subtitle != null) ...[
const SizedBox(height: 2),
Text(
notification.subtitle!,
style: TextStyle(
fontSize: 13,
color: Colors.white.withValues(alpha: 0.8),
),
),
],
],
),
),
// 닫기 버튼
IconButton(
icon: const Icon(Icons.close, color: Colors.white70, size: 20),
onPressed: onDismiss,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
),
],
),
),
),
);
}
(Color, IconData, Color) _getStyleForType(NotificationType type) {
return switch (type) {
NotificationType.levelUp => (
const Color(0xFF1565C0),
Icons.trending_up,
Colors.amber,
),
NotificationType.questComplete => (
const Color(0xFF2E7D32),
Icons.check_circle,
Colors.lightGreen,
),
NotificationType.actComplete => (
const Color(0xFF6A1B9A),
Icons.flag,
Colors.purpleAccent,
),
NotificationType.newSpell => (
const Color(0xFF4527A0),
Icons.auto_fix_high,
Colors.deepPurpleAccent,
),
NotificationType.newEquipment => (
const Color(0xFFE65100),
Icons.shield,
Colors.orange,
),
NotificationType.bossDefeat => (
const Color(0xFFC62828),
Icons.whatshot,
Colors.redAccent,
),
};
}
}

View File

@@ -0,0 +1,244 @@
import 'package:flutter/material.dart';
import 'package:askiineverdie/data/skill_data.dart';
import 'package:askiineverdie/src/core/model/game_state.dart';
import 'package:askiineverdie/src/core/model/skill.dart';
/// 스킬 패널 위젯 (Phase 8: 쿨타임 완료 시 빛남 효과)
///
/// 스킬 목록과 쿨타임 상태를 표시
class SkillPanel extends StatefulWidget {
const SkillPanel({super.key, required this.skillSystem});
final SkillSystemState skillSystem;
@override
State<SkillPanel> createState() => _SkillPanelState();
}
class _SkillPanelState extends State<SkillPanel>
with SingleTickerProviderStateMixin {
late AnimationController _glowController;
late Animation<double> _glowAnimation;
// 이전 쿨타임 완료 상태 추적
final Map<String, bool> _previousReadyState = {};
@override
void initState() {
super.initState();
_glowController = AnimationController(
duration: const Duration(milliseconds: 800),
vsync: this,
);
_glowAnimation = Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(parent: _glowController, curve: Curves.easeInOut),
);
}
@override
void didUpdateWidget(SkillPanel oldWidget) {
super.didUpdateWidget(oldWidget);
_checkCooldownCompletion();
}
void _checkCooldownCompletion() {
// 쿨타임 완료된 스킬이 있으면 glow 애니메이션 시작
for (final skillState in widget.skillSystem.skillStates) {
final skill = _getSkillById(skillState.skillId);
if (skill == null) continue;
final isReady = skillState.isReady(
widget.skillSystem.elapsedMs,
skill.cooldownMs,
);
final wasReady = _previousReadyState[skillState.skillId] ?? true;
// 쿨타임 완료 전환 감지
if (isReady && !wasReady) {
_glowController
..reset()
..repeat(reverse: true);
// 2초 후 glow 중지
Future.delayed(const Duration(seconds: 2), () {
if (mounted) {
_glowController.stop();
_glowController.reset();
}
});
}
_previousReadyState[skillState.skillId] = isReady;
}
}
Skill? _getSkillById(String id) {
return SkillData.allSkills.where((s) => s.id == id).firstOrNull;
}
@override
void dispose() {
_glowController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final skillStates = widget.skillSystem.skillStates;
if (skillStates.isEmpty) {
return const Center(
child: Text('No skills', style: TextStyle(fontSize: 11)),
);
}
return ListView.builder(
itemCount: skillStates.length,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
itemBuilder: (context, index) {
final skillState = skillStates[index];
final skill = _getSkillById(skillState.skillId);
if (skill == null) return const SizedBox.shrink();
final isReady = skillState.isReady(
widget.skillSystem.elapsedMs,
skill.cooldownMs,
);
final remainingMs = skillState.remainingCooldown(
widget.skillSystem.elapsedMs,
skill.cooldownMs,
);
return _SkillRow(
skill: skill,
rank: skillState.rank,
isReady: isReady,
remainingMs: remainingMs,
glowAnimation: _glowAnimation,
);
},
);
}
}
/// 개별 스킬 행 위젯
class _SkillRow extends StatelessWidget {
const _SkillRow({
required this.skill,
required this.rank,
required this.isReady,
required this.remainingMs,
required this.glowAnimation,
});
final Skill skill;
final int rank;
final bool isReady;
final int remainingMs;
final Animation<double> glowAnimation;
@override
Widget build(BuildContext context) {
final cooldownText = isReady
? 'Ready'
: '${(remainingMs / 1000).toStringAsFixed(1)}s';
final skillIcon = _getSkillIcon(skill.type);
final skillColor = _getSkillColor(skill.type);
Widget row = Container(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
children: [
// 스킬 아이콘
Icon(skillIcon, size: 14, color: skillColor),
const SizedBox(width: 4),
// 스킬 이름
Expanded(
child: Text(
skill.name,
style: TextStyle(
fontSize: 10,
color: isReady ? Colors.white : Colors.grey,
),
overflow: TextOverflow.ellipsis,
),
),
// 랭크
Text(
'Lv.$rank',
style: const TextStyle(fontSize: 9, color: Colors.grey),
),
const SizedBox(width: 4),
// 쿨타임 상태
SizedBox(
width: 40,
child: Text(
cooldownText,
style: TextStyle(
fontSize: 9,
color: isReady ? Colors.green : Colors.orange,
fontWeight: isReady ? FontWeight.bold : FontWeight.normal,
),
textAlign: TextAlign.right,
),
),
],
),
);
// 쿨타임 완료 시 glow 효과
if (isReady) {
return AnimatedBuilder(
animation: glowAnimation,
builder: (context, child) {
return Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4),
boxShadow: [
BoxShadow(
color: skillColor.withValues(alpha: glowAnimation.value * 0.5),
blurRadius: 8 * glowAnimation.value,
spreadRadius: 2 * glowAnimation.value,
),
],
),
child: child,
);
},
child: row,
);
}
return row;
}
IconData _getSkillIcon(SkillType type) {
switch (type) {
case SkillType.attack:
return Icons.flash_on;
case SkillType.heal:
return Icons.healing;
case SkillType.buff:
return Icons.arrow_upward;
case SkillType.debuff:
return Icons.arrow_downward;
}
}
Color _getSkillColor(SkillType type) {
switch (type) {
case SkillType.attack:
return Colors.red;
case SkillType.heal:
return Colors.green;
case SkillType.buff:
return Colors.blue;
case SkillType.debuff:
return Colors.purple;
}
}
}

View File

@@ -0,0 +1,209 @@
import 'package:flutter/material.dart';
import 'package:askiineverdie/l10n/app_localizations.dart';
import 'package:askiineverdie/src/core/model/game_state.dart';
/// 스탯 표시 패널 (Phase 8: 실시간 변화 표시)
///
/// 장비 변경이나 버프 시 스탯 변화량을 애니메이션으로 표시
class StatsPanel extends StatefulWidget {
const StatsPanel({super.key, required this.stats});
final Stats stats;
@override
State<StatsPanel> createState() => _StatsPanelState();
}
class _StatsPanelState extends State<StatsPanel>
with SingleTickerProviderStateMixin {
// 변화량 맵 (스탯 이름 -> 변화량)
final Map<String, int> _statChanges = {};
// 변화 애니메이션 컨트롤러
late AnimationController _animationController;
late Animation<double> _fadeAnimation;
@override
void initState() {
super.initState();
_animationController = AnimationController(
duration: const Duration(milliseconds: 2000),
vsync: this,
);
_fadeAnimation = Tween<double>(begin: 1.0, end: 0.0).animate(
CurvedAnimation(
parent: _animationController,
curve: const Interval(0.5, 1.0, curve: Curves.easeOut),
),
);
}
@override
void didUpdateWidget(StatsPanel oldWidget) {
super.didUpdateWidget(oldWidget);
// 스탯 변화 감지
_detectChanges(oldWidget.stats, widget.stats);
}
void _detectChanges(Stats oldStats, Stats newStats) {
final changes = <String, int>{};
if (newStats.str != oldStats.str) {
changes['str'] = newStats.str - oldStats.str;
}
if (newStats.con != oldStats.con) {
changes['con'] = newStats.con - oldStats.con;
}
if (newStats.dex != oldStats.dex) {
changes['dex'] = newStats.dex - oldStats.dex;
}
if (newStats.intelligence != oldStats.intelligence) {
changes['int'] = newStats.intelligence - oldStats.intelligence;
}
if (newStats.wis != oldStats.wis) {
changes['wis'] = newStats.wis - oldStats.wis;
}
if (newStats.cha != oldStats.cha) {
changes['cha'] = newStats.cha - oldStats.cha;
}
if (newStats.hpMax != oldStats.hpMax) {
changes['hpMax'] = newStats.hpMax - oldStats.hpMax;
}
if (newStats.mpMax != oldStats.mpMax) {
changes['mpMax'] = newStats.mpMax - oldStats.mpMax;
}
if (changes.isNotEmpty) {
setState(() {
_statChanges
..clear()
..addAll(changes);
});
// 애니메이션 재시작
_animationController
..reset()
..forward().then((_) {
if (mounted) {
setState(() {
_statChanges.clear();
});
}
});
}
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = L10n.of(context);
final stats = [
('str', l10n.statStr, widget.stats.str),
('con', l10n.statCon, widget.stats.con),
('dex', l10n.statDex, widget.stats.dex),
('int', l10n.statInt, widget.stats.intelligence),
('wis', l10n.statWis, widget.stats.wis),
('cha', l10n.statCha, widget.stats.cha),
('hpMax', l10n.statHpMax, widget.stats.hpMax),
('mpMax', l10n.statMpMax, widget.stats.mpMax),
];
return ListView.builder(
itemCount: stats.length,
padding: const EdgeInsets.symmetric(horizontal: 8),
itemBuilder: (context, index) {
final stat = stats[index];
final change = _statChanges[stat.$1];
return _StatRow(
label: stat.$2,
value: stat.$3,
change: change,
fadeAnimation: _fadeAnimation,
);
},
);
}
}
/// 개별 스탯 행 위젯
class _StatRow extends StatelessWidget {
const _StatRow({
required this.label,
required this.value,
this.change,
required this.fadeAnimation,
});
final String label;
final int value;
final int? change;
final Animation<double> fadeAnimation;
@override
Widget build(BuildContext context) {
return Row(
children: [
SizedBox(
width: 50,
child: Text(label, style: const TextStyle(fontSize: 11)),
),
Text(
'$value',
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.bold),
),
if (change != null) ...[
const SizedBox(width: 4),
AnimatedBuilder(
animation: fadeAnimation,
builder: (context, child) {
return Opacity(
opacity: fadeAnimation.value,
child: _ChangeIndicator(change: change!),
);
},
),
],
],
);
}
}
/// 변화량 표시 위젯
class _ChangeIndicator extends StatelessWidget {
const _ChangeIndicator({required this.change});
final int change;
@override
Widget build(BuildContext context) {
final isPositive = change > 0;
final color = isPositive ? Colors.green : Colors.red;
final icon = isPositive ? Icons.arrow_upward : Icons.arrow_downward;
final text = isPositive ? '+$change' : '$change';
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 10, color: color),
Text(
text,
style: TextStyle(
fontSize: 10,
color: color,
fontWeight: FontWeight.bold,
),
),
],
);
}
}

View File

@@ -1,20 +1,20 @@
import 'package:flutter/material.dart';
import 'package:askiineverdie/l10n/app_localizations.dart';
import 'package:askiineverdie/src/core/animation/ascii_animation_data.dart';
import 'package:askiineverdie/src/core/animation/ascii_animation_type.dart';
import 'package:askiineverdie/src/core/model/combat_event.dart';
import 'package:askiineverdie/src/core/model/game_state.dart';
import 'package:askiineverdie/src/features/game/widgets/ascii_animation_card.dart';
/// 상단 패널: ASCII 애니메이션 + Task Progress 바
///
/// Phase 7: colorTheme 제거됨, 고정 4색 팔레트 사용
class TaskProgressPanel extends StatelessWidget {
const TaskProgressPanel({
super.key,
required this.progress,
required this.speedMultiplier,
required this.onSpeedCycle,
required this.colorTheme,
required this.onThemeCycle,
required this.isPaused,
required this.onPauseToggle,
this.specialAnimation,
@@ -22,13 +22,12 @@ class TaskProgressPanel extends StatelessWidget {
this.shieldName,
this.characterLevel,
this.monsterLevel,
this.latestCombatEvent,
});
final ProgressState progress;
final int speedMultiplier;
final VoidCallback onSpeedCycle;
final AsciiColorTheme colorTheme;
final VoidCallback onThemeCycle;
/// 일시 정지 상태
final bool isPaused;
@@ -43,6 +42,9 @@ class TaskProgressPanel extends StatelessWidget {
final int? characterLevel;
final int? monsterLevel;
/// 최근 전투 이벤트 (애니메이션 동기화용, Phase 5)
final CombatEvent? latestCombatEvent;
@override
Widget build(BuildContext context) {
return Container(
@@ -56,28 +58,26 @@ class TaskProgressPanel extends StatelessWidget {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// ASCII 애니메이션 카드
// ASCII 애니메이션 카드 (Phase 7: 고정 4색 팔레트)
SizedBox(
height: 120,
child: AsciiAnimationCard(
taskType: progress.currentTask.type,
monsterBaseName: progress.currentTask.monsterBaseName,
colorTheme: colorTheme,
specialAnimation: specialAnimation,
weaponName: weaponName,
shieldName: shieldName,
characterLevel: characterLevel,
monsterLevel: monsterLevel,
isPaused: isPaused,
latestCombatEvent: latestCombatEvent,
),
),
const SizedBox(height: 8),
// 상태 메시지 + 버튼들
// 상태 메시지 + 버튼들 (Phase 7: 테마 버튼 제거됨)
Row(
children: [
_buildThemeButton(context),
const SizedBox(width: 4),
_buildPauseButton(context),
const SizedBox(width: 8),
Expanded(
@@ -102,41 +102,6 @@ class TaskProgressPanel extends StatelessWidget {
);
}
Widget _buildThemeButton(BuildContext context) {
final themeLabel = switch (colorTheme) {
AsciiColorTheme.green => 'G',
AsciiColorTheme.amber => 'A',
AsciiColorTheme.white => 'W',
AsciiColorTheme.system => 'S',
};
final themeColor = switch (colorTheme) {
AsciiColorTheme.green => const Color(0xFF00FF00),
AsciiColorTheme.amber => const Color(0xFFFFB000),
AsciiColorTheme.white => Colors.white,
AsciiColorTheme.system => Theme.of(context).colorScheme.primary,
};
return SizedBox(
height: 28,
child: OutlinedButton(
onPressed: onThemeCycle,
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 8),
visualDensity: VisualDensity.compact,
side: BorderSide(color: themeColor.withValues(alpha: 0.5)),
),
child: Text(
themeLabel,
style: TextStyle(
fontWeight: FontWeight.bold,
color: themeColor,
),
),
),
);
}
Widget _buildPauseButton(BuildContext context) {
return SizedBox(
height: 28,

View File

@@ -0,0 +1,460 @@
import 'package:flutter/material.dart';
import 'package:askiineverdie/src/core/l10n/game_data_l10n.dart';
import 'package:askiineverdie/src/core/model/hall_of_fame.dart';
import 'package:askiineverdie/src/core/storage/hall_of_fame_storage.dart';
/// 명예의 전당 화면 (Phase 10: Hall of Fame Screen)
class HallOfFameScreen extends StatefulWidget {
const HallOfFameScreen({super.key});
@override
State<HallOfFameScreen> createState() => _HallOfFameScreenState();
}
class _HallOfFameScreenState extends State<HallOfFameScreen> {
final HallOfFameStorage _storage = HallOfFameStorage();
HallOfFame? _hallOfFame;
bool _isLoading = true;
@override
void initState() {
super.initState();
_loadHallOfFame();
}
Future<void> _loadHallOfFame() async {
final hallOfFame = await _storage.load();
if (mounted) {
setState(() {
_hallOfFame = hallOfFame;
_isLoading = false;
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Hall of Fame'),
centerTitle: true,
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: _buildContent(),
);
}
Widget _buildContent() {
final hallOfFame = _hallOfFame;
if (hallOfFame == null || hallOfFame.isEmpty) {
return _buildEmptyState();
}
return _buildHallOfFameList(hallOfFame);
}
Widget _buildEmptyState() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.emoji_events_outlined,
size: 80,
color: Colors.grey.shade400,
),
const SizedBox(height: 16),
Text(
'No heroes yet',
style: TextStyle(
fontSize: 20,
color: Colors.grey.shade600,
),
),
const SizedBox(height: 8),
Text(
'Defeat the Glitch God to enshrine your legend!',
style: TextStyle(
fontSize: 14,
color: Colors.grey.shade500,
),
textAlign: TextAlign.center,
),
],
),
);
}
Widget _buildHallOfFameList(HallOfFame hallOfFame) {
return Container(
decoration: BoxDecoration(
border: Border.all(color: Colors.amber.shade700, width: 2),
borderRadius: BorderRadius.circular(8),
),
margin: const EdgeInsets.all(16),
child: Column(
children: [
// 헤더
Container(
padding: const EdgeInsets.symmetric(vertical: 12),
decoration: BoxDecoration(
color: Colors.amber.shade700,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(6),
topRight: Radius.circular(6),
),
),
child: const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.emoji_events, color: Colors.white),
SizedBox(width: 8),
Text(
'HALL OF FAME',
style: TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold,
letterSpacing: 2,
),
),
SizedBox(width: 8),
Icon(Icons.emoji_events, color: Colors.white),
],
),
),
// 엔트리 목록
Expanded(
child: ListView.builder(
padding: const EdgeInsets.all(8),
itemCount: hallOfFame.entries.length,
itemBuilder: (context, index) {
final entry = hallOfFame.entries[index];
return _HallOfFameEntryCard(
entry: entry,
rank: index + 1,
);
},
),
),
],
),
);
}
}
/// 명예의 전당 엔트리 카드
class _HallOfFameEntryCard extends StatelessWidget {
const _HallOfFameEntryCard({
required this.entry,
required this.rank,
});
final HallOfFameEntry entry;
final int rank;
@override
Widget build(BuildContext context) {
final rankColor = _getRankColor(rank);
final rankIcon = _getRankIcon(rank);
return Card(
margin: const EdgeInsets.symmetric(vertical: 4),
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
// 순위 표시
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: rankColor.withValues(alpha: 0.2),
shape: BoxShape.circle,
border: Border.all(color: rankColor, width: 2),
),
child: Center(
child: rankIcon != null
? Icon(rankIcon, color: rankColor, size: 20)
: Text(
'$rank',
style: TextStyle(
color: rankColor,
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
),
),
const SizedBox(width: 12),
// 캐릭터 정보
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 이름과 레벨
Row(
children: [
Expanded(
child: Text(
'"${entry.characterName}"',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
overflow: TextOverflow.ellipsis,
),
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
decoration: BoxDecoration(
color: Colors.blue.shade100,
borderRadius: BorderRadius.circular(4),
),
child: Text(
'Lv.${entry.level}',
style: TextStyle(
color: Colors.blue.shade800,
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
),
],
),
const SizedBox(height: 4),
// 종족/클래스
Text(
'${GameDataL10n.getRaceName(context, entry.race)} '
'${GameDataL10n.getKlassName(context, entry.klass)}',
style: TextStyle(
fontSize: 13,
color: Colors.grey.shade700,
),
),
const SizedBox(height: 4),
// 통계
Row(
children: [
_buildStatChip(
Icons.timer,
entry.formattedPlayTime,
Colors.green,
),
const SizedBox(width: 8),
_buildStatChip(
Icons.heart_broken,
'${entry.totalDeaths}',
Colors.red,
),
const SizedBox(width: 8),
_buildStatChip(
Icons.check_circle,
'${entry.questsCompleted}Q',
Colors.orange,
),
],
),
],
),
),
// 클리어 날짜
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Icon(Icons.calendar_today, size: 14, color: Colors.grey.shade500),
const SizedBox(height: 4),
Text(
entry.formattedClearedDate,
style: TextStyle(
fontSize: 11,
color: Colors.grey.shade600,
),
),
],
),
],
),
),
);
}
Widget _buildStatChip(IconData icon, String value, Color color) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 12, color: color),
const SizedBox(width: 2),
Text(
value,
style: TextStyle(
fontSize: 11,
color: color,
fontWeight: FontWeight.w500,
),
),
],
);
}
Color _getRankColor(int rank) {
switch (rank) {
case 1:
return Colors.amber.shade700;
case 2:
return Colors.grey.shade500;
case 3:
return Colors.brown.shade400;
default:
return Colors.blue.shade400;
}
}
IconData? _getRankIcon(int rank) {
switch (rank) {
case 1:
return Icons.emoji_events;
case 2:
return Icons.workspace_premium;
case 3:
return Icons.military_tech;
default:
return null;
}
}
}
/// 게임 클리어 축하 다이얼로그
Future<void> showGameClearDialog(
BuildContext context, {
required HallOfFameEntry entry,
required VoidCallback onNewGame,
required VoidCallback onViewHallOfFame,
}) {
return showDialog(
context: context,
barrierDismissible: false,
builder: (context) => _GameClearDialog(
entry: entry,
onNewGame: onNewGame,
onViewHallOfFame: onViewHallOfFame,
),
);
}
/// 게임 클리어 다이얼로그 위젯
class _GameClearDialog extends StatelessWidget {
const _GameClearDialog({
required this.entry,
required this.onNewGame,
required this.onViewHallOfFame,
});
final HallOfFameEntry entry;
final VoidCallback onNewGame;
final VoidCallback onViewHallOfFame;
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.emoji_events, color: Colors.amber, size: 32),
SizedBox(width: 8),
Text('VICTORY!'),
SizedBox(width: 8),
Icon(Icons.emoji_events, color: Colors.amber, size: 32),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'You have defeated the Glitch God!',
style: TextStyle(fontSize: 16),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
const Divider(),
const SizedBox(height: 16),
// 캐릭터 정보
Text(
'"${entry.characterName}"',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
'${entry.race} ${entry.klass}',
style: TextStyle(color: Colors.grey.shade600),
),
const SizedBox(height: 16),
// 통계
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_buildStat('Level', '${entry.level}'),
_buildStat('Time', entry.formattedPlayTime),
_buildStat('Deaths', '${entry.totalDeaths}'),
_buildStat('Quests', '${entry.questsCompleted}'),
],
),
const SizedBox(height: 16),
const Text(
'Your legend has been enshrined in the Hall of Fame!',
style: TextStyle(
fontStyle: FontStyle.italic,
color: Colors.amber,
),
textAlign: TextAlign.center,
),
],
),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
onViewHallOfFame();
},
child: const Text('View Hall of Fame'),
),
FilledButton(
onPressed: () {
Navigator.of(context).pop();
onNewGame();
},
child: const Text('New Game'),
),
],
);
}
Widget _buildStat(String label, String value) {
return Column(
children: [
Text(
value,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
Text(
label,
style: TextStyle(
fontSize: 11,
color: Colors.grey.shade600,
),
),
],
);
}
}

View File

@@ -2,10 +2,12 @@ import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:askiineverdie/data/class_data.dart';
import 'package:askiineverdie/data/race_data.dart';
import 'package:askiineverdie/l10n/app_localizations.dart';
import 'package:askiineverdie/src/core/l10n/game_data_l10n.dart';
import 'package:askiineverdie/src/core/model/class_traits.dart';
import 'package:askiineverdie/src/core/model/game_state.dart';
import 'package:askiineverdie/src/core/model/pq_config.dart';
import 'package:askiineverdie/src/core/model/race_traits.dart';
import 'package:askiineverdie/src/core/util/deterministic_random.dart';
import 'package:askiineverdie/src/core/util/pq_logic.dart';
@@ -21,12 +23,11 @@ class NewCharacterScreen extends StatefulWidget {
}
class _NewCharacterScreenState extends State<NewCharacterScreen> {
final PqConfig _config = const PqConfig();
final TextEditingController _nameController = TextEditingController();
// 종족(races)과 직업(klasses) 목록
late final List<String> _races;
late final List<String> _klasses;
// 종족(races)과 직업(klasses) 목록 (Phase 5)
final List<RaceTraits> _races = RaceData.all;
final List<ClassTraits> _klasses = ClassData.all;
// 선택된 종족/직업 인덱스
int _selectedRaceIndex = 0;
@@ -54,10 +55,6 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
void initState() {
super.initState();
// 종족/직업 목록 로드 (name|attribute 형식에서 name만 추출)
_races = _config.races.map((e) => e.split('|').first).toList();
_klasses = _config.klasses.map((e) => e.split('|').first).toList();
// 초기 랜덤화
final random = math.Random();
_selectedRaceIndex = random.nextInt(_races.length);
@@ -150,15 +147,19 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
return;
}
// 선택된 종족/클래스 (Phase 5)
final selectedRace = _races[_selectedRaceIndex];
final selectedClass = _klasses[_selectedKlassIndex];
// 게임에 사용할 새 RNG 생성
final gameSeed = math.Random().nextInt(0x7FFFFFFF);
// 원본 Main.pas:1380-1381 - 기본 롤 값(CON.Tag, INT.Tag)만 사용
// 종족/직업 보너스는 스탯에 적용되지 않음 (UI 힌트용)
// Put(Stats,'HP Max',Random(8) + CON.Tag div 6);
// Put(Stats,'MP Max',Random(8) + INT.Tag div 6);
final hpMax = math.Random().nextInt(8) + _con ~/ 6;
final mpMax = math.Random().nextInt(8) + _int ~/ 6;
// HP/MP 초기값 계산
// 원본 공식: Random(8) + CON/6 → 약 1~10 HP (너무 낮음)
// 수정 공식: 50 + Random(8) + CON → 약 60~76 HP (전투 생존 가능)
// 이유: 원본 PQ는 "항상 승리"하지만 이 게임은 실제 전투로 사망 가능
final hpMax = 50 + math.Random().nextInt(8) + _con;
final mpMax = 30 + math.Random().nextInt(8) + _int;
// 원본 Main.pas:1375-1379 - 기본 롤 값 그대로 저장 (보너스 없음)
final finalStats = Stats(
@@ -174,11 +175,13 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
final traits = Traits(
name: name,
race: _races[_selectedRaceIndex],
klass: _klasses[_selectedKlassIndex],
race: selectedRace.name,
klass: selectedClass.name,
level: 1,
motto: '',
guild: '',
raceId: selectedRace.raceId,
classId: selectedClass.classId,
);
// 초기 게임 상태 생성
@@ -401,10 +404,7 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
itemCount: _races.length,
itemBuilder: (context, index) {
final isSelected = index == _selectedRaceIndex;
final raceName = GameDataL10n.getRaceName(
context,
_races[index],
);
final race = _races[index];
return ListTile(
leading: Icon(
isSelected
@@ -415,14 +415,15 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
: null,
),
title: Text(
raceName,
race.name,
style: TextStyle(
fontWeight: isSelected
? FontWeight.bold
: FontWeight.normal,
),
),
dense: true,
subtitle: isSelected ? _buildRaceInfo(race) : null,
dense: !isSelected,
visualDensity: VisualDensity.compact,
onTap: () => setState(() => _selectedRaceIndex = index),
);
@@ -435,6 +436,48 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
);
}
/// 종족 정보 표시 (Phase 5)
Widget _buildRaceInfo(RaceTraits race) {
final statMods = <String>[];
for (final entry in race.statModifiers.entries) {
final sign = entry.value > 0 ? '+' : '';
statMods.add('${_statName(entry.key)} $sign${entry.value}');
}
final passiveDesc = race.passives.isNotEmpty
? race.passives.map((p) => p.description).join(', ')
: '';
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (statMods.isNotEmpty)
Text(
statMods.join(', '),
style: Theme.of(context).textTheme.bodySmall,
),
if (passiveDesc.isNotEmpty)
Text(
passiveDesc,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.primary,
),
),
],
);
}
String _statName(StatType type) {
return switch (type) {
StatType.str => 'STR',
StatType.con => 'CON',
StatType.dex => 'DEX',
StatType.intelligence => 'INT',
StatType.wis => 'WIS',
StatType.cha => 'CHA',
};
}
Widget _buildKlassSection() {
return Card(
child: Padding(
@@ -450,10 +493,7 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
itemCount: _klasses.length,
itemBuilder: (context, index) {
final isSelected = index == _selectedKlassIndex;
final klassName = GameDataL10n.getKlassName(
context,
_klasses[index],
);
final klass = _klasses[index];
return ListTile(
leading: Icon(
isSelected
@@ -464,14 +504,15 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
: null,
),
title: Text(
klassName,
klass.name,
style: TextStyle(
fontWeight: isSelected
? FontWeight.bold
: FontWeight.normal,
),
),
dense: true,
subtitle: isSelected ? _buildClassInfo(klass) : null,
dense: !isSelected,
visualDensity: VisualDensity.compact,
onTap: () => setState(() => _selectedKlassIndex = index),
);
@@ -483,4 +524,35 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
),
);
}
/// 클래스 정보 표시 (Phase 5)
Widget _buildClassInfo(ClassTraits klass) {
final statMods = <String>[];
for (final entry in klass.statModifiers.entries) {
final sign = entry.value > 0 ? '+' : '';
statMods.add('${_statName(entry.key)} $sign${entry.value}');
}
final passiveDesc = klass.passives.isNotEmpty
? klass.passives.map((p) => p.description).join(', ')
: '';
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (statMods.isNotEmpty)
Text(
statMods.join(', '),
style: Theme.of(context).textTheme.bodySmall,
),
if (passiveDesc.isNotEmpty)
Text(
passiveDesc,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.secondary,
),
),
],
);
}
}

View File

@@ -108,9 +108,9 @@ void main() {
});
test('completeQuest and completeAct return deterministic results', () {
// 아스키나라(ASCII-Nara) 세계관 데이터로 업데이트
// 아스키나라(ASCII-Nara) 세계관 데이터로 업데이트 (Phase 7 확장 반영)
final quest = pq_logic.completeQuest(config, DeterministicRandom(33), 4);
expect(quest.caption, 'Transfer this stack trace');
expect(quest.caption, 'Transfer this session token');
expect(quest.reward, pq_logic.RewardKind.item);
expect(quest.monsterName, isNull);

View File

@@ -146,10 +146,10 @@ void main() {
_buildTestApp(GamePlayScreen(controller: controller)),
);
// Stats 섹션 확인
// Stats 섹션 확인 (스크롤로 인해 화면 밖에 있을 수 있음)
expect(find.text('Stats'), findsOneWidget);
expect(find.text('STR'), findsOneWidget);
expect(find.text('CON'), findsOneWidget);
expect(find.text('STR', skipOffstage: false), findsOneWidget);
expect(find.text('CON', skipOffstage: false), findsOneWidget);
await controller.pause(saveOnStop: false);
});

View File

@@ -328,8 +328,8 @@ void main() {
});
test('monsters list matches ASCII-Nara count', () {
// 아스키나라(ASCII-Nara) 세계관 몬스터 개수: 304
expect(config.monsters.length, 304);
// Phase 7 확장 후 몬스터 개수: 369
expect(config.monsters.length, 369);
});
test('spells list is not empty', () {