diff --git a/lib/src/core/animation/canvas/canvas_battle_composer.dart b/lib/src/core/animation/canvas/canvas_battle_composer.dart index 57bd91e..fda677e 100644 --- a/lib/src/core/animation/canvas/canvas_battle_composer.dart +++ b/lib/src/core/animation/canvas/canvas_battle_composer.dart @@ -5,6 +5,7 @@ import 'package:askiineverdie/src/core/animation/canvas/ascii_cell.dart'; import 'package:askiineverdie/src/core/animation/canvas/ascii_layer.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/race_character_frames.dart'; import 'package:askiineverdie/src/core/animation/weapon_category.dart'; import 'package:askiineverdie/src/core/animation/weapon_effects.dart'; @@ -18,6 +19,7 @@ class CanvasBattleComposer { required this.hasShield, required this.monsterCategory, required this.monsterSize, + this.raceId, }); final WeaponCategory weaponCategory; @@ -25,6 +27,9 @@ class CanvasBattleComposer { final MonsterCategory monsterCategory; final MonsterSize monsterSize; + /// 종족 ID (Phase 4: 종족별 캐릭터 애니메이션) + final String? raceId; + /// 프레임 상수 static const int frameWidth = 60; static const int frameHeight = 8; @@ -94,8 +99,25 @@ class CanvasBattleComposer { } /// 캐릭터 레이어 생성 (z=1) + /// + /// Phase 4: 종족별 캐릭터 프레임 지원 AsciiLayer _createCharacterLayer(BattlePhase phase, int subFrame) { - var charFrame = getCharacterFrame(phase, subFrame); + CharacterFrame charFrame; + + // 종족 ID가 있으면 종족별 프레임 사용 + if (raceId != null && raceId!.isNotEmpty) { + final raceData = RaceCharacterFrames.get(raceId!); + if (raceData != null) { + final frames = raceData.getFrames(phase); + charFrame = frames[subFrame % frames.length]; + } else { + // 종족 데이터 없으면 기본 프레임 사용 + charFrame = getCharacterFrame(phase, subFrame); + } + } else { + charFrame = getCharacterFrame(phase, subFrame); + } + if (hasShield) { charFrame = charFrame.withShield(); } diff --git a/lib/src/core/animation/front_screen_animation.dart b/lib/src/core/animation/front_screen_animation.dart index c78e23d..ea65465 100644 --- a/lib/src/core/animation/front_screen_animation.dart +++ b/lib/src/core/animation/front_screen_animation.dart @@ -1,9 +1,12 @@ // 프론트 화면용 Hero vs Glitch God ASCII 애니메이션 데이터 -// 작은 용사가 거대한 Glitch God에 맞서는 장면을 표현 +// 작은 용사(3줄)가 거대한 Glitch God(10줄)에 맞서는 장면 +// 캐릭터 해부학: 머리(1개), 양팔(2개), 양다리(2개) -/// 애니메이션 프레임 (10줄, 6프레임 루프) +/// 애니메이션 프레임 (10줄, 12프레임 루프) const frontScreenAnimationFrames = [ - // 프레임 0: 대치 상태 (방패 들고 대기) + // ======================================================================== + // 프레임 0: 대치 상태 1 (숨쉬기 - 들숨) + // ======================================================================== ''' ░░▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░░ ░▓▓ G L I T C H ▓▓░ @@ -11,12 +14,44 @@ const frontScreenAnimationFrames = [ ░▓▓▓ ◈◈ ◈◈ ▓▓▓░ ░▓▓▓▓ ▼▼▼ ▓▓▓▓░ ░▓▓▓▓▓ ████████ ▓▓▓▓▓░ - o ░▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░ - []|- ░▓▓▓ G O D ▓▓▓░ - / \\ ░▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░ + o ░▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░ + /|\\ ░▓▓▓ G O D ▓▓▓░ + / \\ ░▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░ ~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~''', - // 프레임 1: 용사 전진 (방패 앞으로) + // ======================================================================== + // 프레임 1: 대치 상태 2 (숨쉬기 - 날숨) + // ======================================================================== + ''' + ░░▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░░ + ░▓▓ G L I T C H ▓▓░ + ░▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░ + ░▓▓▓ ◉◉ ◉◉ ▓▓▓░ + ░▓▓▓▓ ~~~ ▓▓▓▓░ + ░▓▓▓▓▓ ████████ ▓▓▓▓▓░ + O ░▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░ + /|\\ ░▓▓▓ G O D ▓▓▓░ + | | ░▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░ +~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~''', + + // ======================================================================== + // 프레임 2: 용사 전진 1 + // ======================================================================== + ''' + ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ + ▓▓ G L I T C H ▓▓ + ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ + ▓▓▓▓ ◈◈ ◈◈ ▓▓▓▓ + ▓▓▓▓▓ ▼▼▼ ▓▓▓▓▓ + o ▓▓▓▓▓▓ ████████ ▓▓▓▓▓▓ + /|\\ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ + / \\ ▓▓▓▓ G O D ▓▓▓▓ + ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ +~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~''', + + // ======================================================================== + // 프레임 3: 용사 전진 2 + // ======================================================================== ''' ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ ▓▓ G L I T C H ▓▓ @@ -24,66 +59,134 @@ const frontScreenAnimationFrames = [ ▓▓▓▓ ◉◉ ◉◉ ▓▓▓▓ ▓▓▓▓▓ ~~~ ▓▓▓▓▓ o ▓▓▓▓▓▓ ████████ ▓▓▓▓▓▓ - []|> ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ - / \\ ▓▓▓▓ G O D ▓▓▓▓ + /|\\ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ + | | ▓▓▓▓ G O D ▓▓▓▓ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ ~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~''', - // 프레임 2: 용사 공격 준비 (방패 방어 자세) + // ======================================================================== + // 프레임 4: 공격 준비 1 (무기 들기) + // ======================================================================== ''' ▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒ ▒▓ G L I T C H ▓▒ ▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒ ▒▓▓▓ ◈◈ ◈◈ ▓▓▓▒ ▒▓▓▓▓ ▼▼▼ ▓▓▓▓▒ - o\\ ▒▓▓▓▓▓ ████████ ▓▓▓▓▓▒ - []=|== ▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒ + o ▒▓▓▓▓▓ ████████ ▓▓▓▓▓▒ + \\| ▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒ / \\ ▒▓▓▓ G O D ▓▓▓▒ ▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒ ~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~''', - // 프레임 3: 용사 공격 (글리치 갓 데미지) - ''' - ░▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░ - ░▓ G#L@I*T&C!H ▓░ - ░▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░ - ░▓▓▓ X X X X ▓▓▓░ - o\\ ░▓▓▓▓ !!! ▓▓▓▓░ - []=|===> ░▓▓▓▓▓ ████████ ▓▓▓▓▓░ - / \\ ░▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░ - ░▓▓▓ G O D ▓▓▓░ - ░▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░ -~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~''', - - // 프레임 4: 글리치 갓 반격 준비 (방패로 방어) - ''' - ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ - ▓▓ G L I T C H ▓▓ - ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ - ▓▓▓▓ @@ @@ ▓▓▓▓ - ▓▓▓▓▓ <=== ▓▓▓▓▓ - []o ▓▓▓▓▓▓ ████████ ▓▓▓▓▓▓ - |\\ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ - / \\ ▓▓▓▓ G O D ▓▓▓▓ - ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ -~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~''', - - // 프레임 5: 글리치 갓 공격 (용사 방패로 막기) + // ======================================================================== + // 프레임 5: 공격 준비 2 (무기 뒤로) + // ======================================================================== ''' ▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒ ▒▓ G L I T C H ▓▒ ▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒ ▒▓▓▓ ◉◉ ◉◉ ▓▓▓▒ - ▒▓▓▓▓ <====== ▓▓▓▓▒ - []o * ▒▓▓▓▓▓ ████████ ▓▓▓▓▓▒ - |/ ▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒ - |\\ ▒▓▓▓ G O D ▓▓▓▒ + ▒▓▓▓▓ ~~~ ▓▓▓▓▒ + o ▒▓▓▓▓▓ ████████ ▓▓▓▓▓▒ + \\|/ ▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒ + / \\ ▒▓▓▓ G O D ▓▓▓▒ + ▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒ +~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~''', + + // ======================================================================== + // 프레임 6: 공격 1 (휘두르기) + // ======================================================================== + ''' + ░▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░ + ░▓ G L I T C H ▓░ + ░▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░ + ░▓▓▓ ◈◈ ◈◈ ▓▓▓░ + ░▓▓▓▓ ▼▼▼ ▓▓▓▓░ + o ░▓▓▓▓▓ ████████ ▓▓▓▓▓░ + \\|- ░▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░ + / \\ ░▓▓▓ G O D ▓▓▓░ + ░▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░ +~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~''', + + // ======================================================================== + // 프레임 7: 공격 2 (이펙트 + 히트) + // ======================================================================== + ''' + ░▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░ + ░▓ G#L@I*T&C!H ▓░ + ░▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░ + ░▓▓▓ X X X X ▓▓▓░ + ░▓▓▓▓ !!! ▓▓▓▓░ + o ░▓▓▓▓▓ ████████ ▓▓▓▓▓░ + \\|-=>* ░▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░ + / \\ ░▓▓▓ G O D ▓▓▓░ + ░▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░ +~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~''', + + // ======================================================================== + // 프레임 8: 보스 반격 준비 1 + // ======================================================================== + ''' + ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ + ▓▓ G L I T C H ▓▓ + ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ + ▓▓▓▓ @@ @@ ▓▓▓▓ + ▓▓▓▓▓ <=== ▓▓▓▓▓ + o ▓▓▓▓▓▓ ████████ ▓▓▓▓▓▓ + /|\\ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ + / \\ ▓▓▓▓ G O D ▓▓▓▓ + ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ +~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~''', + + // ======================================================================== + // 프레임 9: 보스 반격 2 (글리치 증가) + // ======================================================================== + ''' + ▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒ + ▒▓ G L I T C H ▓▒ + ▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒ + ▒▓▓▓ ◉◉ ◉◉ ▓▓▓▒ + ▒▓▓▓▓ <====== ▓▓▓▓▒ + o ▒▓▓▓▓▓ ████████ ▓▓▓▓▓▒ + /|\\ ▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒ + / \\ ▒▓▓▓ G O D ▓▓▓▒ + ▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒ +~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~''', + + // ======================================================================== + // 프레임 10: 방어 1 (방패 들기) + // ======================================================================== + ''' + ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ + ▓▓ G L I T C H ▓▓ + ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ + ▓▓▓▓ @@ @@ ▓▓▓▓ + ▓▓▓▓▓ <==== ▓▓▓▓▓ + o[] ▓▓▓▓▓▓ ████████ ▓▓▓▓▓▓ + /| ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ + / \\ ▓▓▓▓ G O D ▓▓▓▓ + ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ +~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~''', + + // ======================================================================== + // 프레임 11: 방어 2 (충격파 막기) + // ======================================================================== + ''' + ▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒ + ▒▓ G L I T C H ▓▒ + ▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒ + ▒▓▓▓ ◉◉ ◉◉ ▓▓▓▒ + ▒▓▓▓▓ <===== ▓▓▓▓▒ + o[]* ▒▓▓▓▓▓ ████████ ▓▓▓▓▓▒ + /| ▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒ + / \\ ▒▓▓▓ G O D ▓▓▓▒ ▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒ ~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~''', ]; /// 애니메이션 프레임 간격 (밀리초) -const frontScreenAnimationIntervalMs = 400; +const frontScreenAnimationIntervalMs = 350; /// 애니메이션 총 프레임 수 -const frontScreenAnimationFrameCount = 6; +const frontScreenAnimationFrameCount = 12; diff --git a/lib/src/core/animation/race_character_frames.dart b/lib/src/core/animation/race_character_frames.dart new file mode 100644 index 0000000..eb4708a --- /dev/null +++ b/lib/src/core/animation/race_character_frames.dart @@ -0,0 +1,754 @@ +// 종족별 ASCII 캐릭터 프레임 데이터 +// 모든 캐릭터는 3줄 × 6자 폭으로 통일 (보스 10줄과 대비) + +import 'package:askiineverdie/src/core/animation/character_frames.dart'; + +/// 종족별 캐릭터 프레임 저장소 +class RaceCharacterFrames { + RaceCharacterFrames._(); + + /// 종족 ID로 프레임 데이터 조회 + static RaceFrameData? get(String raceId) => _raceFrames[raceId]; + + /// 기본 프레임 (종족 미지정 시) + static RaceFrameData get defaultFrames => _raceFrames['byte_human']!; + + /// 모든 종족 ID 목록 + static List get allRaceIds => _raceFrames.keys.toList(); +} + +/// 종족별 프레임 데이터 +class RaceFrameData { + const RaceFrameData({ + required this.raceId, + required this.idle, + required this.prepare, + required this.attack, + required this.hit, + required this.recover, + }); + + final String raceId; + final List idle; // 4 프레임 + final List prepare; // 3 프레임 + final List attack; // 5 프레임 + final List hit; // 3 프레임 + final List recover; // 3 프레임 + + /// BattlePhase로 프레임 목록 조회 + List getFrames(BattlePhase phase) { + return switch (phase) { + BattlePhase.idle => idle, + BattlePhase.prepare => prepare, + BattlePhase.attack => attack, + BattlePhase.hit => hit, + BattlePhase.recover => recover, + }; + } +} + +// ============================================================================ +// 종족별 프레임 데이터 (21개 종족) +// 구조: [머리, 몸통+팔, 다리] - 모두 6자 폭 +// ============================================================================ + +const _raceFrames = { + // -------------------------------------------------------------------------- + // 기본형 (Byte Human, Buffer Dwarf) + // -------------------------------------------------------------------------- + 'byte_human': RaceFrameData( + raceId: 'byte_human', + idle: [ + CharacterFrame([r' o ', r' /|\ ', r' / \ ']), + CharacterFrame([r' o ', r' /|\ ', r' | | ']), + CharacterFrame([r' o ', r' /|\ ', r' / \ ']), + CharacterFrame([r' O ', r' /|\ ', r' / \ ']), + ], + prepare: [ + CharacterFrame([r' o ', r' \|\ ', r' / \ ']), + CharacterFrame([r' o ', r' \| ', r' / \ ']), + CharacterFrame([r' o ', r' \|/ ', r' / \ ']), + ], + attack: [ + CharacterFrame([r' o ', r' \| ', r' / \ ']), + CharacterFrame([r' o ', r' \|- ', r' / \ ']), + CharacterFrame([r' o ', r' \|-- ', r' / \ ']), + CharacterFrame([r' o ', r'\|-=>', r' / \ ']), + CharacterFrame([r' o ', r' /|\ ', r' / \ ']), + ], + hit: [ + CharacterFrame([r' o ', r'/|\* ', r' / \ ']), + CharacterFrame([r' o ', r'/|\ *', r' / \ ']), + CharacterFrame([r' o ', r' /|\ ', r' / \ ']), + ], + recover: [ + CharacterFrame([r' o ', r' /|\ ', r' | ']), + CharacterFrame([r' o ', r' /|\ ', r' / \ ']), + CharacterFrame([r' o ', r' /|\ ', r' / \ ']), + ], + ), + + 'buffer_dwarf': RaceFrameData( + raceId: 'buffer_dwarf', + idle: [ + CharacterFrame([r' o ', r' /█\ ', r' / \ ']), + CharacterFrame([r' o ', r' /█\ ', r' | | ']), + CharacterFrame([r' o ', r' /█\ ', r' / \ ']), + CharacterFrame([r' O ', r' /█\ ', r' / \ ']), + ], + prepare: [ + CharacterFrame([r' o ', r' \█\ ', r' / \ ']), + CharacterFrame([r' o ', r' \█ ', r' / \ ']), + CharacterFrame([r' o ', r' \█/ ', r' / \ ']), + ], + attack: [ + CharacterFrame([r' o ', r' \█ ', r' / \ ']), + CharacterFrame([r' o ', r' \█- ', r' / \ ']), + CharacterFrame([r' o ', r' \█-- ', r' / \ ']), + CharacterFrame([r' o ', r'\█-=>', r' / \ ']), + CharacterFrame([r' o ', r' /█\ ', r' / \ ']), + ], + hit: [ + CharacterFrame([r' o ', r'/█\* ', r' / \ ']), + CharacterFrame([r' o ', r'/█\ *', r' / \ ']), + CharacterFrame([r' o ', r' /█\ ', r' / \ ']), + ], + recover: [ + CharacterFrame([r' o ', r' /█\ ', r' | ']), + CharacterFrame([r' o ', r' /█\ ', r' / \ ']), + CharacterFrame([r' o ', r' /█\ ', r' / \ ']), + ], + ), + + // -------------------------------------------------------------------------- + // 대형 (Kernel Giant, Heap Troll) + // -------------------------------------------------------------------------- + 'kernel_giant': RaceFrameData( + raceId: 'kernel_giant', + idle: [ + CharacterFrame([r' O ', r' /█\ ', r' ┘ └ ']), + CharacterFrame([r' O ', r' /█\ ', r' | | ']), + CharacterFrame([r' O ', r' /█\ ', r' ┘ └ ']), + CharacterFrame([r' O ', r' /█\ ', r' ┘ └ ']), + ], + prepare: [ + CharacterFrame([r' O ', r' \█\ ', r' ┘ └ ']), + CharacterFrame([r' O ', r' \█ ', r' ┘ └ ']), + CharacterFrame([r' O ', r' \█/ ', r' ┘ └ ']), + ], + attack: [ + CharacterFrame([r' O ', r' \█ ', r' ┘ └ ']), + CharacterFrame([r' O ', r' \█- ', r' ┘ └ ']), + CharacterFrame([r' O ', r' \█-- ', r' ┘ └ ']), + CharacterFrame([r' O ', r'\█-=>', r' ┘ └ ']), + CharacterFrame([r' O ', r' /█\ ', r' ┘ └ ']), + ], + hit: [ + CharacterFrame([r' O ', r'/█\* ', r' ┘ └ ']), + CharacterFrame([r' O ', r'/█\ *', r' ┘ └ ']), + CharacterFrame([r' O ', r' /█\ ', r' ┘ └ ']), + ], + recover: [ + CharacterFrame([r' O ', r' /█\ ', r' || ']), + CharacterFrame([r' O ', r' /█\ ', r' ┘ └ ']), + CharacterFrame([r' O ', r' /█\ ', r' ┘ └ ']), + ], + ), + + 'heap_troll': RaceFrameData( + raceId: 'heap_troll', + idle: [ + CharacterFrame([r' Ö ', r' /▓\ ', r' ┘ └ ']), + CharacterFrame([r' Ö ', r' /▓\ ', r' | | ']), + CharacterFrame([r' Ö ', r' /▓\ ', r' ┘ └ ']), + CharacterFrame([r' Ö ', r' /▓\ ', r' ┘ └ ']), + ], + prepare: [ + CharacterFrame([r' Ö ', r' \▓\ ', r' ┘ └ ']), + CharacterFrame([r' Ö ', r' \▓ ', r' ┘ └ ']), + CharacterFrame([r' Ö ', r' \▓/ ', r' ┘ └ ']), + ], + attack: [ + CharacterFrame([r' Ö ', r' \▓ ', r' ┘ └ ']), + CharacterFrame([r' Ö ', r' \▓- ', r' ┘ └ ']), + CharacterFrame([r' Ö ', r' \▓-- ', r' ┘ └ ']), + CharacterFrame([r' Ö ', r'\▓-=>', r' ┘ └ ']), + CharacterFrame([r' Ö ', r' /▓\ ', r' ┘ └ ']), + ], + hit: [ + CharacterFrame([r' Ö ', r'/▓\* ', r' ┘ └ ']), + CharacterFrame([r' Ö ', r'/▓\ *', r' ┘ └ ']), + CharacterFrame([r' Ö ', r' /▓\ ', r' ┘ └ ']), + ], + recover: [ + CharacterFrame([r' Ö ', r' /▓\ ', r' || ']), + CharacterFrame([r' Ö ', r' /▓\ ', r' ┘ └ ']), + CharacterFrame([r' Ö ', r' /▓\ ', r' ┘ └ ']), + ], + ), + + // -------------------------------------------------------------------------- + // 전사형 (Protocol Paladin, Flag Knight, Array Orc) + // -------------------------------------------------------------------------- + 'protocol_paladin': RaceFrameData( + raceId: 'protocol_paladin', + idle: [ + CharacterFrame([r' o ', r' /|+ ', r' / \ ']), + CharacterFrame([r' o ', r' /|+ ', r' | | ']), + CharacterFrame([r' o ', r' /|+ ', r' / \ ']), + CharacterFrame([r' O ', r' /|+ ', r' / \ ']), + ], + prepare: [ + CharacterFrame([r' o ', r' \|+ ', r' / \ ']), + CharacterFrame([r' o ', r' \| ', r' / \ ']), + CharacterFrame([r' o ', r' \|/ ', r' / \ ']), + ], + attack: [ + CharacterFrame([r' o ', r' \| ', r' / \ ']), + CharacterFrame([r' o ', r' \|- ', r' / \ ']), + CharacterFrame([r' o ', r' \|-- ', r' / \ ']), + CharacterFrame([r' o ', r'\|-+>', r' / \ ']), + CharacterFrame([r' o ', r' /|+ ', r' / \ ']), + ], + hit: [ + CharacterFrame([r' o ', r'/|+* ', r' / \ ']), + CharacterFrame([r' o ', r'/|+ *', r' / \ ']), + CharacterFrame([r' o ', r' /|+ ', r' / \ ']), + ], + recover: [ + CharacterFrame([r' o ', r' /|+ ', r' | ']), + CharacterFrame([r' o ', r' /|+ ', r' / \ ']), + CharacterFrame([r' o ', r' /|+ ', r' / \ ']), + ], + ), + + 'flag_knight': RaceFrameData( + raceId: 'flag_knight', + idle: [ + CharacterFrame([r' ♦ ', r' /|] ', r' / \ ']), + CharacterFrame([r' ♦ ', r' /|] ', r' | | ']), + CharacterFrame([r' ♦ ', r' /|] ', r' / \ ']), + CharacterFrame([r' ◆ ', r' /|] ', r' / \ ']), + ], + prepare: [ + CharacterFrame([r' ♦ ', r' \|] ', r' / \ ']), + CharacterFrame([r' ♦ ', r' \| ', r' / \ ']), + CharacterFrame([r' ♦ ', r' \|/ ', r' / \ ']), + ], + attack: [ + CharacterFrame([r' ♦ ', r' \| ', r' / \ ']), + CharacterFrame([r' ♦ ', r' \|- ', r' / \ ']), + CharacterFrame([r' ♦ ', r' \|-- ', r' / \ ']), + CharacterFrame([r' ♦ ', r'\|-->', r' / \ ']), + CharacterFrame([r' ♦ ', r' /|] ', r' / \ ']), + ], + hit: [ + CharacterFrame([r' ♦ ', r'/|]* ', r' / \ ']), + CharacterFrame([r' ♦ ', r'/|] *', r' / \ ']), + CharacterFrame([r' ♦ ', r' /|] ', r' / \ ']), + ], + recover: [ + CharacterFrame([r' ♦ ', r' /|] ', r' | ']), + CharacterFrame([r' ♦ ', r' /|] ', r' / \ ']), + CharacterFrame([r' ♦ ', r' /|] ', r' / \ ']), + ], + ), + + 'array_orc': RaceFrameData( + raceId: 'array_orc', + idle: [ + CharacterFrame([r' ö ', r' /▒\ ', r' / \ ']), + CharacterFrame([r' ö ', r' /▒\ ', r' | | ']), + CharacterFrame([r' ö ', r' /▒\ ', r' / \ ']), + CharacterFrame([r' Ö ', r' /▒\ ', r' / \ ']), + ], + prepare: [ + CharacterFrame([r' ö ', r' \▒\ ', r' / \ ']), + CharacterFrame([r' ö ', r' \▒ ', r' / \ ']), + CharacterFrame([r' ö ', r' \▒/ ', r' / \ ']), + ], + attack: [ + CharacterFrame([r' ö ', r' \▒ ', r' / \ ']), + CharacterFrame([r' ö ', r' \▒- ', r' / \ ']), + CharacterFrame([r' ö ', r' \▒-- ', r' / \ ']), + CharacterFrame([r' ö ', r'\▒-=>', r' / \ ']), + CharacterFrame([r' ö ', r' /▒\ ', r' / \ ']), + ], + hit: [ + CharacterFrame([r' ö ', r'/▒\* ', r' / \ ']), + CharacterFrame([r' ö ', r'/▒\ *', r' / \ ']), + CharacterFrame([r' ö ', r' /▒\ ', r' / \ ']), + ], + recover: [ + CharacterFrame([r' ö ', r' /▒\ ', r' | ']), + CharacterFrame([r' ö ', r' /▒\ ', r' / \ ']), + CharacterFrame([r' ö ', r' /▒\ ', r' / \ ']), + ], + ), + + // -------------------------------------------------------------------------- + // 마법형 (Loop Wizard, Lambda Druid, Recursive Sage) + // -------------------------------------------------------------------------- + 'loop_wizard': RaceFrameData( + raceId: 'loop_wizard', + idle: [ + CharacterFrame([r' o~ ', r' /|) ', r' / \ ']), + CharacterFrame([r' o~ ', r' /|) ', r' | | ']), + CharacterFrame([r' o~ ', r' /|) ', r' / \ ']), + CharacterFrame([r' O~ ', r' /|) ', r' / \ ']), + ], + prepare: [ + CharacterFrame([r' o~ ', r' \|) ', r' / \ ']), + CharacterFrame([r' o~ ', r' \| ', r' / \ ']), + CharacterFrame([r' o~ ', r' \|/ ', r' / \ ']), + ], + attack: [ + CharacterFrame([r' o~ ', r' \| ', r' / \ ']), + CharacterFrame([r' o~ ', r' \|~ ', r' / \ ']), + CharacterFrame([r' o~ ', r' \|~~ ', r' / \ ']), + CharacterFrame([r' o~ ', r'\|~~*', r' / \ ']), + CharacterFrame([r' o~ ', r' /|) ', r' / \ ']), + ], + hit: [ + CharacterFrame([r' o~ ', r'/|)* ', r' / \ ']), + CharacterFrame([r' o~ ', r'/|) *', r' / \ ']), + CharacterFrame([r' o~ ', r' /|) ', r' / \ ']), + ], + recover: [ + CharacterFrame([r' o~ ', r' /|) ', r' | ']), + CharacterFrame([r' o~ ', r' /|) ', r' / \ ']), + CharacterFrame([r' o~ ', r' /|) ', r' / \ ']), + ], + ), + + 'lambda_druid': RaceFrameData( + raceId: 'lambda_druid', + idle: [ + CharacterFrame([r' o♣ ', r' /|) ', r' / \ ']), + CharacterFrame([r' o♣ ', r' /|) ', r' | | ']), + CharacterFrame([r' o♣ ', r' /|) ', r' / \ ']), + CharacterFrame([r' O♣ ', r' /|) ', r' / \ ']), + ], + prepare: [ + CharacterFrame([r' o♣ ', r' \|) ', r' / \ ']), + CharacterFrame([r' o♣ ', r' \| ', r' / \ ']), + CharacterFrame([r' o♣ ', r' \|/ ', r' / \ ']), + ], + attack: [ + CharacterFrame([r' o♣ ', r' \| ', r' / \ ']), + CharacterFrame([r' o♣ ', r' \|~ ', r' / \ ']), + CharacterFrame([r' o♣ ', r' \|~~ ', r' / \ ']), + CharacterFrame([r' o♣ ', r'\|~~♣', r' / \ ']), + CharacterFrame([r' o♣ ', r' /|) ', r' / \ ']), + ], + hit: [ + CharacterFrame([r' o♣ ', r'/|)* ', r' / \ ']), + CharacterFrame([r' o♣ ', r'/|) *', r' / \ ']), + CharacterFrame([r' o♣ ', r' /|) ', r' / \ ']), + ], + recover: [ + CharacterFrame([r' o♣ ', r' /|) ', r' | ']), + CharacterFrame([r' o♣ ', r' /|) ', r' / \ ']), + CharacterFrame([r' o♣ ', r' /|) ', r' / \ ']), + ], + ), + + 'recursive_sage': RaceFrameData( + raceId: 'recursive_sage', + idle: [ + CharacterFrame([r' o∞ ', r' /|) ', r' / \ ']), + CharacterFrame([r' o∞ ', r' /|) ', r' | | ']), + CharacterFrame([r' o∞ ', r' /|) ', r' / \ ']), + CharacterFrame([r' O∞ ', r' /|) ', r' / \ ']), + ], + prepare: [ + CharacterFrame([r' o∞ ', r' \|) ', r' / \ ']), + CharacterFrame([r' o∞ ', r' \| ', r' / \ ']), + CharacterFrame([r' o∞ ', r' \|/ ', r' / \ ']), + ], + attack: [ + CharacterFrame([r' o∞ ', r' \| ', r' / \ ']), + CharacterFrame([r' o∞ ', r' \|~ ', r' / \ ']), + CharacterFrame([r' o∞ ', r' \|~~ ', r' / \ ']), + CharacterFrame([r' o∞ ', r'\|~~∞', r' / \ ']), + CharacterFrame([r' o∞ ', r' /|) ', r' / \ ']), + ], + hit: [ + CharacterFrame([r' o∞ ', r'/|)* ', r' / \ ']), + CharacterFrame([r' o∞ ', r'/|) *', r' / \ ']), + CharacterFrame([r' o∞ ', r' /|) ', r' / \ ']), + ], + recover: [ + CharacterFrame([r' o∞ ', r' /|) ', r' | ']), + CharacterFrame([r' o∞ ', r' /|) ', r' / \ ']), + CharacterFrame([r' o∞ ', r' /|) ', r' / \ ']), + ], + ), + + // -------------------------------------------------------------------------- + // 언데드/엘프 (Coredump Undead, Null Elf, Callback Priest) + // -------------------------------------------------------------------------- + 'coredump_undead': RaceFrameData( + raceId: 'coredump_undead', + idle: [ + CharacterFrame([r' ☠ ', r' /|\ ', r' / \ ']), + CharacterFrame([r' ☠ ', r' /|\ ', r' | | ']), + CharacterFrame([r' ☠ ', r' /|\ ', r' / \ ']), + CharacterFrame([r' ☠ ', r' /|\ ', r' / \ ']), + ], + prepare: [ + CharacterFrame([r' ☠ ', r' \|\ ', r' / \ ']), + CharacterFrame([r' ☠ ', r' \| ', r' / \ ']), + CharacterFrame([r' ☠ ', r' \|/ ', r' / \ ']), + ], + attack: [ + CharacterFrame([r' ☠ ', r' \| ', r' / \ ']), + CharacterFrame([r' ☠ ', r' \|- ', r' / \ ']), + CharacterFrame([r' ☠ ', r' \|-- ', r' / \ ']), + CharacterFrame([r' ☠ ', r'\|-~>', r' / \ ']), + CharacterFrame([r' ☠ ', r' /|\ ', r' / \ ']), + ], + hit: [ + CharacterFrame([r' ☠ ', r'/|\~ ', r' / \ ']), + CharacterFrame([r' ☠ ', r'/|\ ~', r' / \ ']), + CharacterFrame([r' ☠ ', r' /|\ ', r' / \ ']), + ], + recover: [ + CharacterFrame([r' ☠ ', r' /|\ ', r' | ']), + CharacterFrame([r' ☠ ', r' /|\ ', r' / \ ']), + CharacterFrame([r' ☠ ', r' /|\ ', r' / \ ']), + ], + ), + + 'null_elf': RaceFrameData( + raceId: 'null_elf', + idle: [ + CharacterFrame([r' ö ', r' /|\ ', r' / \ ']), + CharacterFrame([r' ö ', r' /|\ ', r' | | ']), + CharacterFrame([r' ö ', r' /|\ ', r' / \ ']), + CharacterFrame([r' Ö ', r' /|\ ', r' / \ ']), + ], + prepare: [ + CharacterFrame([r' ö ', r' \|\ ', r' / \ ']), + CharacterFrame([r' ö ', r' \| ', r' / \ ']), + CharacterFrame([r' ö ', r' \|/ ', r' / \ ']), + ], + attack: [ + CharacterFrame([r' ö ', r' \| ', r' / \ ']), + CharacterFrame([r' ö ', r' \|~ ', r' / \ ']), + CharacterFrame([r' ö ', r' \|~~ ', r' / \ ']), + CharacterFrame([r' ö ', r'\|~~*', r' / \ ']), + CharacterFrame([r' ö ', r' /|\ ', r' / \ ']), + ], + hit: [ + CharacterFrame([r' ö ', r'/|\* ', r' / \ ']), + CharacterFrame([r' ö ', r'/|\ *', r' / \ ']), + CharacterFrame([r' ö ', r' /|\ ', r' / \ ']), + ], + recover: [ + CharacterFrame([r' ö ', r' /|\ ', r' | ']), + CharacterFrame([r' ö ', r' /|\ ', r' / \ ']), + CharacterFrame([r' ö ', r' /|\ ', r' / \ ']), + ], + ), + + 'callback_priest': RaceFrameData( + raceId: 'callback_priest', + idle: [ + CharacterFrame([r' ô ', r' /|\ ', r' / \ ']), + CharacterFrame([r' ô ', r' /|\ ', r' | | ']), + CharacterFrame([r' ô ', r' /|\ ', r' / \ ']), + CharacterFrame([r' Ô ', r' /|\ ', r' / \ ']), + ], + prepare: [ + CharacterFrame([r' ô ', r' \|\ ', r' / \ ']), + CharacterFrame([r' ô ', r' \| ', r' / \ ']), + CharacterFrame([r' ô ', r' \|/ ', r' / \ ']), + ], + attack: [ + CharacterFrame([r' ô ', r' \| ', r' / \ ']), + CharacterFrame([r' ô ', r' \|+ ', r' / \ ']), + CharacterFrame([r' ô ', r' \|++ ', r' / \ ']), + CharacterFrame([r' ô ', r'\|++*', r' / \ ']), + CharacterFrame([r' ô ', r' /|\ ', r' / \ ']), + ], + hit: [ + CharacterFrame([r' ô ', r'/|\+ ', r' / \ ']), + CharacterFrame([r' ô ', r'/|\ +', r' / \ ']), + CharacterFrame([r' ô ', r' /|\ ', r' / \ ']), + ], + recover: [ + CharacterFrame([r' ô ', r' /|\ ', r' | ']), + CharacterFrame([r' ô ', r' /|\ ', r' / \ ']), + CharacterFrame([r' ô ', r' /|\ ', r' / \ ']), + ], + ), + + // -------------------------------------------------------------------------- + // 소형 (Bit Halfling, Stack Goblin, Cache Imp) + // -------------------------------------------------------------------------- + 'bit_halfling': RaceFrameData( + raceId: 'bit_halfling', + idle: [ + CharacterFrame([r' o ', r' /|\ ', r' Y ']), + CharacterFrame([r' o ', r' /|\ ', r' | ']), + CharacterFrame([r' o ', r' /|\ ', r' Y ']), + CharacterFrame([r' O ', r' /|\ ', r' Y ']), + ], + prepare: [ + CharacterFrame([r' o ', r' \|\ ', r' Y ']), + CharacterFrame([r' o ', r' \| ', r' Y ']), + CharacterFrame([r' o ', r' \|/ ', r' Y ']), + ], + attack: [ + CharacterFrame([r' o ', r' \| ', r' Y ']), + CharacterFrame([r' o ', r' \|- ', r' Y ']), + CharacterFrame([r' o ', r' \|-- ', r' Y ']), + CharacterFrame([r' o ', r'\|-=>', r' Y ']), + CharacterFrame([r' o ', r' /|\ ', r' Y ']), + ], + hit: [ + CharacterFrame([r' o ', r'/|\* ', r' Y ']), + CharacterFrame([r' o ', r'/|\ *', r' Y ']), + CharacterFrame([r' o ', r' /|\ ', r' Y ']), + ], + recover: [ + CharacterFrame([r' o ', r' /|\ ', r' | ']), + CharacterFrame([r' o ', r' /|\ ', r' Y ']), + CharacterFrame([r' o ', r' /|\ ', r' Y ']), + ], + ), + + 'stack_goblin': RaceFrameData( + raceId: 'stack_goblin', + idle: [ + CharacterFrame([r' ◦ ', r' /|\ ', r' Y ']), + CharacterFrame([r' ◦ ', r' /|\ ', r' | ']), + CharacterFrame([r' ◦ ', r' /|\ ', r' Y ']), + CharacterFrame([r' ● ', r' /|\ ', r' Y ']), + ], + prepare: [ + CharacterFrame([r' ◦ ', r' \|\ ', r' Y ']), + CharacterFrame([r' ◦ ', r' \| ', r' Y ']), + CharacterFrame([r' ◦ ', r' \|/ ', r' Y ']), + ], + attack: [ + CharacterFrame([r' ◦ ', r' \| ', r' Y ']), + CharacterFrame([r' ◦ ', r' \|- ', r' Y ']), + CharacterFrame([r' ◦ ', r' \|-- ', r' Y ']), + CharacterFrame([r' ◦ ', r'\|-=>', r' Y ']), + CharacterFrame([r' ◦ ', r' /|\ ', r' Y ']), + ], + hit: [ + CharacterFrame([r' ◦ ', r'/|\* ', r' Y ']), + CharacterFrame([r' ◦ ', r'/|\ *', r' Y ']), + CharacterFrame([r' ◦ ', r' /|\ ', r' Y ']), + ], + recover: [ + CharacterFrame([r' ◦ ', r' /|\ ', r' | ']), + CharacterFrame([r' ◦ ', r' /|\ ', r' Y ']), + CharacterFrame([r' ◦ ', r' /|\ ', r' Y ']), + ], + ), + + 'cache_imp': RaceFrameData( + raceId: 'cache_imp', + idle: [ + CharacterFrame([r' ^ ', r' /|\ ', r' Y ']), + CharacterFrame([r' ^ ', r' /|\ ', r' | ']), + CharacterFrame([r' ^ ', r' /|\ ', r' Y ']), + CharacterFrame([r' ^ ', r' /|\ ', r' Y ']), + ], + prepare: [ + CharacterFrame([r' ^ ', r' \|\ ', r' Y ']), + CharacterFrame([r' ^ ', r' \| ', r' Y ']), + CharacterFrame([r' ^ ', r' \|/ ', r' Y ']), + ], + attack: [ + CharacterFrame([r' ^ ', r' \| ', r' Y ']), + CharacterFrame([r' ^ ', r' \|- ', r' Y ']), + CharacterFrame([r' ^ ', r' \|-- ', r' Y ']), + CharacterFrame([r' ^ ', r'\|-->', r' Y ']), + CharacterFrame([r' ^ ', r' /|\ ', r' Y ']), + ], + hit: [ + CharacterFrame([r' ^ ', r'/|\* ', r' Y ']), + CharacterFrame([r' ^ ', r'/|\ *', r' Y ']), + CharacterFrame([r' ^ ', r' /|\ ', r' Y ']), + ], + recover: [ + CharacterFrame([r' ^ ', r' /|\ ', r' | ']), + CharacterFrame([r' ^ ', r' /|\ ', r' Y ']), + CharacterFrame([r' ^ ', r' /|\ ', r' Y ']), + ], + ), + + // -------------------------------------------------------------------------- + // 영체 (Thread Spirit, Pointer Fairy) + // -------------------------------------------------------------------------- + 'thread_spirit': RaceFrameData( + raceId: 'thread_spirit', + idle: [ + CharacterFrame([r' ·o· ', r' | ', r' ~~~ ']), + CharacterFrame([r' ·o· ', r' | ', r' ~~ ']), + CharacterFrame([r' ·o· ', r' | ', r' ~~~ ']), + CharacterFrame([r' ·O· ', r' | ', r' ~~~ ']), + ], + prepare: [ + CharacterFrame([r' ·o· ', r' \| ', r' ~~~ ']), + CharacterFrame([r' ·o· ', r' \ ', r' ~~~ ']), + CharacterFrame([r' ·o· ', r' \/ ', r' ~~~ ']), + ], + attack: [ + CharacterFrame([r' ·o· ', r' \ ', r' ~~~ ']), + CharacterFrame([r' ·o· ', r' \~ ', r' ~~~ ']), + CharacterFrame([r' ·o· ', r' \~~ ', r' ~~~ ']), + CharacterFrame([r' ·o· ', r'\~~* ', r' ~~~ ']), + CharacterFrame([r' ·o· ', r' | ', r' ~~~ ']), + ], + hit: [ + CharacterFrame([r' ·o· ', r' |* ', r' ~~~ ']), + CharacterFrame([r' ·o· ', r' | * ', r' ~~~ ']), + CharacterFrame([r' ·o· ', r' | ', r' ~~~ ']), + ], + recover: [ + CharacterFrame([r' ·o· ', r' | ', r' ~~ ']), + CharacterFrame([r' ·o· ', r' | ', r' ~~~ ']), + CharacterFrame([r' ·o· ', r' | ', r' ~~~ ']), + ], + ), + + 'pointer_fairy': RaceFrameData( + raceId: 'pointer_fairy', + idle: [ + CharacterFrame([r' *o* ', r' | ', r' ~~~ ']), + CharacterFrame([r' *o* ', r' | ', r' ~~ ']), + CharacterFrame([r' *o* ', r' | ', r' ~~~ ']), + CharacterFrame([r' *O* ', r' | ', r' ~~~ ']), + ], + prepare: [ + CharacterFrame([r' *o* ', r' \| ', r' ~~~ ']), + CharacterFrame([r' *o* ', r' \ ', r' ~~~ ']), + CharacterFrame([r' *o* ', r' \/ ', r' ~~~ ']), + ], + attack: [ + CharacterFrame([r' *o* ', r' \ ', r' ~~~ ']), + CharacterFrame([r' *o* ', r' \* ', r' ~~~ ']), + CharacterFrame([r' *o* ', r' \** ', r' ~~~ ']), + CharacterFrame([r' *o* ', r'\**+ ', r' ~~~ ']), + CharacterFrame([r' *o* ', r' | ', r' ~~~ ']), + ], + hit: [ + CharacterFrame([r' *o* ', r' |+ ', r' ~~~ ']), + CharacterFrame([r' *o* ', r' | + ', r' ~~~ ']), + CharacterFrame([r' *o* ', r' | ', r' ~~~ ']), + ], + recover: [ + CharacterFrame([r' *o* ', r' | ', r' ~~ ']), + CharacterFrame([r' *o* ', r' | ', r' ~~~ ']), + CharacterFrame([r' *o* ', r' | ', r' ~~~ ']), + ], + ), + + // -------------------------------------------------------------------------- + // 레인저형 (Index Ranger, Iterator Rogue) + // -------------------------------------------------------------------------- + 'index_ranger': RaceFrameData( + raceId: 'index_ranger', + idle: [ + CharacterFrame([r' o ', r' /|\ ', r' λ \ ']), + CharacterFrame([r' o ', r' /|\ ', r' | | ']), + CharacterFrame([r' o ', r' /|\ ', r' λ \ ']), + CharacterFrame([r' O ', r' /|\ ', r' λ \ ']), + ], + prepare: [ + CharacterFrame([r' o ', r' \|\ ', r' λ \ ']), + CharacterFrame([r' o ', r' \| ', r' λ \ ']), + CharacterFrame([r' o ', r' \|/ ', r' λ \ ']), + ], + attack: [ + CharacterFrame([r' o ', r' \| ', r' λ \ ']), + CharacterFrame([r' o ', r' \|) ', r' λ \ ']), + CharacterFrame([r' o ', r' \|)) ', r' λ \ ']), + CharacterFrame([r' o ', r'\|))>', r' λ \ ']), + CharacterFrame([r' o ', r' /|\ ', r' λ \ ']), + ], + hit: [ + CharacterFrame([r' o ', r'/|\* ', r' λ \ ']), + CharacterFrame([r' o ', r'/|\ *', r' λ \ ']), + CharacterFrame([r' o ', r' /|\ ', r' λ \ ']), + ], + recover: [ + CharacterFrame([r' o ', r' /|\ ', r' | ']), + CharacterFrame([r' o ', r' /|\ ', r' λ \ ']), + CharacterFrame([r' o ', r' /|\ ', r' λ \ ']), + ], + ), + + 'iterator_rogue': RaceFrameData( + raceId: 'iterator_rogue', + idle: [ + CharacterFrame([r' o ', r' /|\ ', r' λ \ ']), + CharacterFrame([r' o ', r' /|\ ', r' | | ']), + CharacterFrame([r' o ', r' /|\ ', r' λ \ ']), + CharacterFrame([r' o ', r' /|\ ', r' λ \ ']), + ], + prepare: [ + CharacterFrame([r' o ', r' \|\ ', r' λ \ ']), + CharacterFrame([r' o ', r' \| ', r' λ \ ']), + CharacterFrame([r' o ', r' \|/ ', r' λ \ ']), + ], + attack: [ + CharacterFrame([r' o ', r' \| ', r' λ \ ']), + CharacterFrame([r' o ', r' \|- ', r' λ \ ']), + CharacterFrame([r' o ', r' \|-- ', r' λ \ ']), + CharacterFrame([r' o ', r'\|--x', r' λ \ ']), + CharacterFrame([r' o ', r' /|\ ', r' λ \ ']), + ], + hit: [ + CharacterFrame([r' o ', r'/|\x ', r' λ \ ']), + CharacterFrame([r' o ', r'/|\ x', r' λ \ ']), + CharacterFrame([r' o ', r' /|\ ', r' λ \ ']), + ], + recover: [ + CharacterFrame([r' o ', r' /|\ ', r' | ']), + CharacterFrame([r' o ', r' /|\ ', r' λ \ ']), + CharacterFrame([r' o ', r' /|\ ', r' λ \ ']), + ], + ), + + // -------------------------------------------------------------------------- + // 노움 (Register Gnome) + // -------------------------------------------------------------------------- + 'register_gnome': RaceFrameData( + raceId: 'register_gnome', + idle: [ + CharacterFrame([r' ô ', r' /|\ ', r' ∧ ']), + CharacterFrame([r' ô ', r' /|\ ', r' | ']), + CharacterFrame([r' ô ', r' /|\ ', r' ∧ ']), + CharacterFrame([r' Ô ', r' /|\ ', r' ∧ ']), + ], + prepare: [ + CharacterFrame([r' ô ', r' \|\ ', r' ∧ ']), + CharacterFrame([r' ô ', r' \| ', r' ∧ ']), + CharacterFrame([r' ô ', r' \|/ ', r' ∧ ']), + ], + attack: [ + CharacterFrame([r' ô ', r' \| ', r' ∧ ']), + CharacterFrame([r' ô ', r' \|* ', r' ∧ ']), + CharacterFrame([r' ô ', r' \|** ', r' ∧ ']), + CharacterFrame([r' ô ', r'\|**>', r' ∧ ']), + CharacterFrame([r' ô ', r' /|\ ', r' ∧ ']), + ], + hit: [ + CharacterFrame([r' ô ', r'/|\* ', r' ∧ ']), + CharacterFrame([r' ô ', r'/|\ *', r' ∧ ']), + CharacterFrame([r' ô ', r' /|\ ', r' ∧ ']), + ], + recover: [ + CharacterFrame([r' ô ', r' /|\ ', r' | ']), + CharacterFrame([r' ô ', r' /|\ ', r' ∧ ']), + CharacterFrame([r' ô ', r' /|\ ', r' ∧ ']), + ], + ), +}; diff --git a/lib/src/features/front/widgets/hero_vs_boss_animation.dart b/lib/src/features/front/widgets/hero_vs_boss_animation.dart index 8645ca2..5bec0b5 100644 --- a/lib/src/features/front/widgets/hero_vs_boss_animation.dart +++ b/lib/src/features/front/widgets/hero_vs_boss_animation.dart @@ -3,12 +3,13 @@ import 'dart:math'; import 'package:flutter/material.dart'; +import 'package:askiineverdie/data/race_data.dart'; import 'package:askiineverdie/src/core/animation/front_screen_animation.dart'; /// 프론트 화면용 Hero vs Glitch God ASCII 애니메이션 위젯 /// /// 작은 용사가 거대한 Glitch God에 맞서는 루프 애니메이션 -/// 항상 검은 배경에 흰색 텍스트, 특수 효과만 컬러로 표시 +/// RichText로 문자별 색상 적용, 랜덤 종족 변경 지원 class HeroVsBossAnimation extends StatefulWidget { const HeroVsBossAnimation({super.key}); @@ -18,23 +19,30 @@ class HeroVsBossAnimation extends StatefulWidget { class _HeroVsBossAnimationState extends State { int _currentFrame = 0; - Timer? _timer; + Timer? _animationTimer; + Timer? _raceChangeTimer; final Random _random = Random(); + // 현재 종족 (랜덤 변경) + String _currentRaceId = 'byte_human'; + int _raceIndex = 0; + @override void initState() { super.initState(); _startAnimation(); + _startRaceChangeTimer(); } @override void dispose() { - _timer?.cancel(); + _animationTimer?.cancel(); + _raceChangeTimer?.cancel(); super.dispose(); } void _startAnimation() { - _timer = Timer.periodic( + _animationTimer = Timer.periodic( const Duration(milliseconds: frontScreenAnimationIntervalMs), (_) { if (mounted) { @@ -47,6 +55,26 @@ class _HeroVsBossAnimationState extends State { ); } + /// 8초마다 랜덤 종족 변경 + void _startRaceChangeTimer() { + _raceChangeTimer = Timer.periodic( + const Duration(seconds: 8), + (_) => _changeRace(), + ); + } + + void _changeRace() { + if (!mounted) return; + + final allRaces = RaceData.all; + if (allRaces.isEmpty) return; + + setState(() { + _raceIndex = (_raceIndex + 1) % allRaces.length; + _currentRaceId = allRaces[_raceIndex].raceId; + }); + } + /// 글리치 효과: 랜덤 문자 대체 String _applyGlitchEffect(String frame) { // 10% 확률로 글리치 효과 적용 @@ -66,12 +94,52 @@ class _HeroVsBossAnimationState extends State { return chars.join(); } + /// 문자별 색상 결정 + Color _getCharColor(String char) { + // 공격/이펙트 (시안) + if ('><=!+'.contains(char)) return Colors.cyan; + // 데미지/글리치 (마젠타) + if ('*~@#\$%&'.contains(char)) return const Color(0xFFFF00FF); + // 보스 눈 (빨강) + if ('◈◉X'.contains(char)) return Colors.red; + // 보스 타이틀 텍스트 (시안) + if ('GLITCH'.contains(char) || 'GOD'.contains(char)) return Colors.cyan; + // 기본 (흰색) + return Colors.white; + } + + /// 문자열을 색상별 TextSpan으로 변환 + TextSpan _buildColoredTextSpan(String text) { + final spans = []; + + for (final char in text.split('')) { + final color = _getCharColor(char); + spans.add( + TextSpan( + text: char, + style: TextStyle( + fontFamily: 'JetBrainsMono', + fontSize: 10, + height: 1.1, + color: color, + letterSpacing: 0, + ), + ), + ); + } + + return TextSpan(children: spans); + } + @override Widget build(BuildContext context) { final frame = _applyGlitchEffect( frontScreenAnimationFrames[_currentFrame], ); + // 현재 종족 이름 (UI 표시용) + final raceName = RaceData.findById(_currentRaceId)?.name ?? 'Hero'; + return Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 16), decoration: BoxDecoration( @@ -93,23 +161,26 @@ class _HeroVsBossAnimationState extends State { ), child: Column( children: [ - // ASCII 애니메이션 (흰색 텍스트) + // ASCII 애니메이션 (컬러 적용) FittedBox( fit: BoxFit.scaleDown, - child: Text( - frame, - style: const TextStyle( - fontFamily: 'JetBrainsMono', - fontSize: 10, - height: 1.1, - color: Colors.white, - letterSpacing: 0, - ), + child: RichText( + text: _buildColoredTextSpan(frame), ), ), const SizedBox(height: 8), // 하단 효과 바 (컬러) _buildEffectBar(), + const SizedBox(height: 4), + // 현재 종족 표시 + Text( + '♦ $raceName', + style: TextStyle( + fontFamily: 'JetBrainsMono', + fontSize: 9, + color: Colors.cyan.withValues(alpha: 0.7), + ), + ), ], ), ); diff --git a/lib/src/features/game/game_play_screen.dart b/lib/src/features/game/game_play_screen.dart index aee184b..a8b5dd5 100644 --- a/lib/src/features/game/game_play_screen.dart +++ b/lib/src/features/game/game_play_screen.dart @@ -485,10 +485,24 @@ class _GamePlayScreenState extends State fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, ), ), - onTap: () { + onTap: () async { Navigator.pop(context); // 다이얼로그 닫기 + // 안전한 언어 변경: 전체 화면 재생성 + final navigator = Navigator.of(this.context); + await widget.controller.pause(saveOnStop: true); game_l10n.setGameLocale(locale); - setState(() {}); + if (mounted) { + await widget.controller.resume(); + navigator.pushReplacement( + MaterialPageRoute( + builder: (_) => GamePlayScreen( + controller: widget.controller, + currentThemeMode: widget.currentThemeMode, + onThemeModeChange: widget.onThemeModeChange, + ), + ), + ); + } }, ); } @@ -573,6 +587,8 @@ class _GamePlayScreenState extends State notificationService: _notificationService, specialAnimation: _specialAnimation, onLanguageChange: (locale) async { + // navigator 참조를 async gap 전에 저장 + final navigator = Navigator.of(context); // 1. 현재 상태 저장 await widget.controller.pause(saveOnStop: true); // 2. 로케일 변경 @@ -580,7 +596,7 @@ class _GamePlayScreenState extends State // 3. 화면 재생성 (전체 UI 재구성) if (context.mounted) { await widget.controller.resume(); - Navigator.of(context).pushReplacement( + navigator.pushReplacement( MaterialPageRoute( builder: (_) => GamePlayScreen( controller: widget.controller, @@ -689,6 +705,7 @@ class _GamePlayScreenState extends State monsterLevel: state.progress.currentTask.monsterLevel, latestCombatEvent: state.progress.currentCombat?.recentEvents.lastOrNull, + raceId: state.traits.raceId, ), // 메인 3패널 영역 diff --git a/lib/src/features/game/layouts/mobile_carousel_layout.dart b/lib/src/features/game/layouts/mobile_carousel_layout.dart index a2bc2b7..80e132b 100644 --- a/lib/src/features/game/layouts/mobile_carousel_layout.dart +++ b/lib/src/features/game/layouts/mobile_carousel_layout.dart @@ -405,6 +405,7 @@ class _MobileCarouselLayoutState extends State { monsterLevel: state.progress.currentTask.monsterLevel, latestCombatEvent: state.progress.currentCombat?.recentEvents.lastOrNull, + raceId: state.traits.raceId, ), // 중앙: 캐로셀 (PageView) diff --git a/lib/src/features/game/widgets/ascii_animation_card.dart b/lib/src/features/game/widgets/ascii_animation_card.dart index 7b5f1e6..8a0158f 100644 --- a/lib/src/features/game/widgets/ascii_animation_card.dart +++ b/lib/src/features/game/widgets/ascii_animation_card.dart @@ -45,6 +45,7 @@ class AsciiAnimationCard extends StatefulWidget { this.monsterLevel, this.isPaused = false, this.latestCombatEvent, + this.raceId, }); final TaskType taskType; @@ -75,6 +76,9 @@ class AsciiAnimationCard extends StatefulWidget { /// 최근 전투 이벤트 (애니메이션 동기화용) final CombatEvent? latestCombatEvent; + /// 종족 ID (Phase 4: 종족별 캐릭터 애니메이션) + final String? raceId; + @override State createState() => _AsciiAnimationCardState(); } @@ -168,7 +172,8 @@ class _AsciiAnimationCardState extends State { oldWidget.monsterBaseName != widget.monsterBaseName || oldWidget.weaponName != widget.weaponName || oldWidget.shieldName != widget.shieldName || - oldWidget.monsterLevel != widget.monsterLevel) { + oldWidget.monsterLevel != widget.monsterLevel || + oldWidget.raceId != widget.raceId) { _updateAnimation(); } } @@ -391,6 +396,7 @@ class _AsciiAnimationCardState extends State { hasShield: hasShield, monsterCategory: monsterCategory, monsterSize: monsterSize, + raceId: widget.raceId, ); // 환경 타입 추론 diff --git a/lib/src/features/game/widgets/enhanced_animation_panel.dart b/lib/src/features/game/widgets/enhanced_animation_panel.dart index 0d0524b..d0f1416 100644 --- a/lib/src/features/game/widgets/enhanced_animation_panel.dart +++ b/lib/src/features/game/widgets/enhanced_animation_panel.dart @@ -30,6 +30,7 @@ class EnhancedAnimationPanel extends StatefulWidget { this.characterLevel, this.monsterLevel, this.latestCombatEvent, + this.raceId, }); final ProgressState progress; @@ -46,6 +47,9 @@ class EnhancedAnimationPanel extends StatefulWidget { final int? monsterLevel; final CombatEvent? latestCombatEvent; + /// 종족 ID (Phase 4: 종족별 캐릭터 애니메이션) + final String? raceId; + @override State createState() => _EnhancedAnimationPanelState(); } @@ -183,6 +187,7 @@ class _EnhancedAnimationPanelState extends State monsterLevel: widget.monsterLevel, isPaused: widget.isPaused, latestCombatEvent: widget.latestCombatEvent, + raceId: widget.raceId, ), ), diff --git a/lib/src/features/game/widgets/task_progress_panel.dart b/lib/src/features/game/widgets/task_progress_panel.dart index 13ce42d..cb564a6 100644 --- a/lib/src/features/game/widgets/task_progress_panel.dart +++ b/lib/src/features/game/widgets/task_progress_panel.dart @@ -23,6 +23,7 @@ class TaskProgressPanel extends StatelessWidget { this.characterLevel, this.monsterLevel, this.latestCombatEvent, + this.raceId, }); final ProgressState progress; @@ -45,6 +46,9 @@ class TaskProgressPanel extends StatelessWidget { /// 최근 전투 이벤트 (애니메이션 동기화용, Phase 5) final CombatEvent? latestCombatEvent; + /// 종족 ID (Phase 4: 종족별 캐릭터 애니메이션) + final String? raceId; + @override Widget build(BuildContext context) { return Container( @@ -71,6 +75,7 @@ class TaskProgressPanel extends StatelessWidget { monsterLevel: monsterLevel, isPaused: isPaused, latestCombatEvent: latestCombatEvent, + raceId: raceId, ), ), const SizedBox(height: 8), diff --git a/lib/src/features/new_character/new_character_screen.dart b/lib/src/features/new_character/new_character_screen.dart index baefe09..3fba2f8 100644 --- a/lib/src/features/new_character/new_character_screen.dart +++ b/lib/src/features/new_character/new_character_screen.dart @@ -12,6 +12,7 @@ import 'package:askiineverdie/src/core/model/race_traits.dart'; import 'package:askiineverdie/src/core/util/deterministic_random.dart'; import 'package:askiineverdie/src/core/l10n/game_data_l10n.dart'; import 'package:askiineverdie/src/core/util/pq_logic.dart'; +import 'package:askiineverdie/src/features/new_character/widgets/race_preview.dart'; /// 캐릭터 생성 화면 (NewGuy.pas 포팅) class NewCharacterScreen extends StatefulWidget { @@ -264,6 +265,14 @@ class _NewCharacterScreenState extends State { _buildStatsSection(), const SizedBox(height: 16), + // 종족 미리보기 (Phase 5: 종족별 캐릭터 애니메이션) + Center( + child: RacePreview( + raceId: _races[_selectedRaceIndex].raceId, + ), + ), + const SizedBox(height: 16), + // 종족/직업 선택 섹션 Row( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/lib/src/features/new_character/widgets/race_preview.dart b/lib/src/features/new_character/widgets/race_preview.dart new file mode 100644 index 0000000..2de556f --- /dev/null +++ b/lib/src/features/new_character/widgets/race_preview.dart @@ -0,0 +1,129 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import 'package:askiineverdie/src/core/animation/character_frames.dart'; +import 'package:askiineverdie/src/core/animation/race_character_frames.dart'; + +/// 종족 미리보기 위젯 +/// +/// 새 캐릭터 생성 화면에서 선택한 종족의 idle 애니메이션을 보여줌. +/// RichText 기반 색상 적용. +class RacePreview extends StatefulWidget { + const RacePreview({ + super.key, + required this.raceId, + }); + + /// 종족 ID (예: "byte_human", "kernel_giant") + final String raceId; + + @override + State createState() => _RacePreviewState(); +} + +class _RacePreviewState extends State { + Timer? _timer; + int _currentFrame = 0; + + @override + void initState() { + super.initState(); + _startAnimation(); + } + + @override + void didUpdateWidget(RacePreview oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.raceId != widget.raceId) { + _currentFrame = 0; + } + } + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } + + void _startAnimation() { + _timer = Timer.periodic(const Duration(milliseconds: 400), (_) { + if (mounted) { + setState(() { + _currentFrame++; + }); + } + }); + } + + @override + Widget build(BuildContext context) { + final raceData = RaceCharacterFrames.get(widget.raceId); + final frames = raceData?.idle ?? RaceCharacterFrames.defaultFrames.idle; + final frame = frames[_currentFrame % frames.length]; + + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.black, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3), + ), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // 캐릭터 프레임 + _buildColoredFrame(frame), + ], + ), + ); + } + + /// RichText 기반 색상 적용 프레임 + Widget _buildColoredFrame(CharacterFrame frame) { + return Column( + mainAxisSize: MainAxisSize.min, + children: frame.lines.map((line) => _buildColoredLine(line)).toList(), + ); + } + + /// 한 줄을 색상 적용하여 렌더링 + Widget _buildColoredLine(String line) { + final spans = []; + + for (var i = 0; i < line.length; i++) { + final char = line[i]; + spans.add( + TextSpan( + text: char, + style: TextStyle( + fontFamily: 'monospace', + fontSize: 18, + height: 1.2, + color: _getCharColor(char), + ), + ), + ); + } + + return RichText( + text: TextSpan(children: spans), + ); + } + + /// 문자별 색상 매핑 + Color _getCharColor(String char) { + // 공격/이펙트 (시안) + if ('><=!+~'.contains(char)) return Colors.cyan; + // 데미지/글리치 (마젠타) + if ('*@#\$%&'.contains(char)) return const Color(0xFFFF00FF); + // 특수 문자 (노랑) + if ('☠◈◉'.contains(char)) return Colors.yellow; + // 대형 문자 (밝은 녹색) + if ('█▓░'.contains(char)) return Colors.lightGreen; + // 기본 (흰색) + return Colors.white; + } +}