Compare commits
12 Commits
e451703161
...
cf8fdaecde
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf8fdaecde | ||
|
|
45147da5ec | ||
|
|
27e21bb064 | ||
|
|
810bf6867d | ||
|
|
9af5c4dc13 | ||
|
|
7c7f3b0d9e | ||
|
|
abcb89d334 | ||
|
|
8cbef3475b | ||
|
|
bfcec44ac7 | ||
|
|
a6ba3d5d2e | ||
|
|
97d9875e00 | ||
|
|
ec27389e9b |
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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
395
lib/data/story_data.dart
Normal 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',
|
||||
};
|
||||
@@ -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" },
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
"equipment": "Equipment",
|
||||
"inventory": "Inventory",
|
||||
"encumbrance": "Encumbrance",
|
||||
"combatLog": "戦闘ログ",
|
||||
"plotDevelopment": "Plot Development",
|
||||
"quests": "Quests",
|
||||
"traitName": "Name",
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
"equipment": "장비",
|
||||
"inventory": "인벤토리",
|
||||
"encumbrance": "적재량",
|
||||
"combatLog": "전투 로그",
|
||||
"plotDevelopment": "스토리 진행",
|
||||
"quests": "퀘스트",
|
||||
"traitName": "이름",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -97,6 +97,9 @@ class L10nJa extends L10n {
|
||||
@override
|
||||
String get encumbrance => 'Encumbrance';
|
||||
|
||||
@override
|
||||
String get combatLog => '戦闘ログ';
|
||||
|
||||
@override
|
||||
String get plotDevelopment => 'Plot Development';
|
||||
|
||||
|
||||
@@ -97,6 +97,9 @@ class L10nKo extends L10n {
|
||||
@override
|
||||
String get encumbrance => '적재량';
|
||||
|
||||
@override
|
||||
String get combatLog => '전투 로그';
|
||||
|
||||
@override
|
||||
String get plotDevelopment => '스토리 진행';
|
||||
|
||||
|
||||
@@ -97,6 +97,9 @@ class L10nZh extends L10n {
|
||||
@override
|
||||
String get encumbrance => 'Encumbrance';
|
||||
|
||||
@override
|
||||
String get combatLog => '战斗日志';
|
||||
|
||||
@override
|
||||
String get plotDevelopment => 'Plot Development';
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
"equipment": "Equipment",
|
||||
"inventory": "Inventory",
|
||||
"encumbrance": "Encumbrance",
|
||||
"combatLog": "战斗日志",
|
||||
"plotDevelopment": "Plot Development",
|
||||
"quests": "Quests",
|
||||
"traitName": "Name",
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'|_____|_____|'],
|
||||
],
|
||||
|
||||
@@ -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' / \ ',
|
||||
]),
|
||||
];
|
||||
|
||||
81
lib/src/core/constants/ascii_colors.dart
Normal file
81
lib/src/core/constants/ascii_colors.dart
Normal 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,
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
// 전투 상태 초기화
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 유틸리티
|
||||
// ============================================================================
|
||||
|
||||
186
lib/src/core/engine/story_service.dart
Normal file
186
lib/src/core/engine/story_service.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
191
lib/src/core/model/combat_event.dart
Normal file
191
lib/src/core/model/combat_event.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
192
lib/src/core/model/hall_of_fame.dart
Normal file
192
lib/src/core/model/hall_of_fame.dart
Normal 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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
153
lib/src/core/notification/notification_service.dart
Normal file
153
lib/src/core/notification/notification_service.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
81
lib/src/core/storage/hall_of_fame_storage.dart
Normal file
81
lib/src/core/storage/hall_of_fame_storage.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
419
lib/src/core/util/balance_constants.dart
Normal file
419
lib/src/core/util/balance_constants.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
284
lib/src/features/game/widgets/cinematic_view.dart
Normal file
284
lib/src/features/game/widgets/cinematic_view.dart
Normal 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);
|
||||
}
|
||||
162
lib/src/features/game/widgets/combat_log.dart
Normal file
162
lib/src/features/game/widgets/combat_log.dart
Normal 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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
415
lib/src/features/game/widgets/hp_mp_bar.dart
Normal file
415
lib/src/features/game/widgets/hp_mp_bar.dart
Normal 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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
218
lib/src/features/game/widgets/notification_overlay.dart
Normal file
218
lib/src/features/game/widgets/notification_overlay.dart
Normal 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,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
244
lib/src/features/game/widgets/skill_panel.dart
Normal file
244
lib/src/features/game/widgets/skill_panel.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
209
lib/src/features/game/widgets/stats_panel.dart
Normal file
209
lib/src/features/game/widgets/stats_panel.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
460
lib/src/features/hall_of_fame/hall_of_fame_screen.dart
Normal file
460
lib/src/features/hall_of_fame/hall_of_fame_screen.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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', () {
|
||||
|
||||
Reference in New Issue
Block a user