From 598c25e4c90a32e306619bf5e55dcc35166760df Mon Sep 17 00:00:00 2001 From: JiWoong Sul Date: Sat, 13 Dec 2025 18:22:50 +0900 Subject: [PATCH] =?UTF-8?q?fix(animation):=20ASCII=20=EC=95=A0=EB=8B=88?= =?UTF-8?q?=EB=A9=94=EC=9D=B4=EC=85=98=20=EB=86=92=EB=82=AE=EC=9D=B4/?= =?UTF-8?q?=EA=B3=B5=EB=B0=B1=20=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - walkingAnimation, townAnimation 4줄 → 3줄 통일 - character_frames.dart 모든 프레임 폭 6자로 통일 - _compose() 이펙트 Y 위치 동적 계산 (하드코딩 제거) - withShield() 3줄 캐릭터용으로 수정 (index 3 → index 1) - BattleComposer 캔버스 시스템 및 배경 합성 추가 - 무기 카테고리별 이펙트, 몬스터 크기/색상 시스템 구현 --- .../core/animation/ascii_animation_data.dart | 470 ++++-------- lib/src/core/animation/background_data.dart | 178 +++++ lib/src/core/animation/background_layer.dart | 92 +++ lib/src/core/animation/battle_composer.dart | 713 ++++++++++++++++++ lib/src/core/animation/character_frames.dart | 178 +++++ lib/src/core/animation/monster_colors.dart | 192 +++++ lib/src/core/animation/monster_size.dart | 49 ++ lib/src/core/animation/weapon_category.dart | 109 +++ lib/src/core/animation/weapon_effects.dart | 184 +++++ lib/src/core/engine/progress_service.dart | 1 + lib/src/core/model/game_state.dart | 6 + lib/src/features/game/game_play_screen.dart | 4 + .../game/widgets/ascii_animation_card.dart | 217 +++++- .../game/widgets/task_progress_panel.dart | 14 + 14 files changed, 2052 insertions(+), 355 deletions(-) create mode 100644 lib/src/core/animation/background_data.dart create mode 100644 lib/src/core/animation/background_layer.dart create mode 100644 lib/src/core/animation/battle_composer.dart create mode 100644 lib/src/core/animation/character_frames.dart create mode 100644 lib/src/core/animation/monster_colors.dart create mode 100644 lib/src/core/animation/monster_size.dart create mode 100644 lib/src/core/animation/weapon_category.dart create mode 100644 lib/src/core/animation/weapon_effects.dart diff --git a/lib/src/core/animation/ascii_animation_data.dart b/lib/src/core/animation/ascii_animation_data.dart index 47d49b1..9293218 100644 --- a/lib/src/core/animation/ascii_animation_data.dart +++ b/lib/src/core/animation/ascii_animation_data.dart @@ -177,470 +177,290 @@ MonsterCategory getMonsterCategory(String? monsterBaseName) { return MonsterCategory.beast; } -/// 기본 전투 애니메이션 (beast - 고양이 모양) +/// 기본 전투 애니메이션 (beast - 고양이 모양, 심플 3줄) const battleAnimationBeast = AsciiAnimationData( frames: [ // 프레임 1: 대치 ''' - ,O, /\\___/\\ - /( )\\ ( o o ) - / \\ vs ( =^= ) - _| |_ /| |\\ - | | / | | \\ - _| |_ | |_____| | - |_________| |___| |___|''', + o vs /\\_/\\ + /|\\ ( o.o ) + / \\ > ^ <''', // 프레임 2: 공격 준비 ''' - O /\\___/\\ - /|\\----o ( o o ) - / \\ ( =^= ) - _| |_ /| |\\ - | | / | | \\ - _| |_ | |_____| | - |_________| |___| |___|''', + o----o /\\_/\\ + /|\\ ( o.o ) + / \\ > ^ <''', // 프레임 3: 공격 중 ''' - O o--->/\\___/\\ - /|\\-----------> ( X X ) - / \\ ( =^= ) - _| |_ /| |\\ - | | / | | \\ - _| |_ | |_____| | - |_________| |___| |___|''', + o o-----> /\\_/\\ + /|\\ ( X.X ) + / \\ > ^ <''', // 프레임 4: 히트 ''' - O /\\___/\\ - /|\\ **** ( X X ) **** - / \\ ** ( =^= ) ** - _| |_ /| |\\ - | | / | | \\ - _| |_ | |_____| | - |_________| |___| |___|''', + o **** /\\_/\\ + /|\\ *** ( X.X ) *** + / \\ > ~ <''', // 프레임 5: 복귀 ''' - \\O/ /\\___/\\ - | ( - - ) - / \\ ( =^= ) - _| |_ /| |\\ - | | / | | \\ - _| |_ | |_____| | - |_________| |___| |___|''', + \\o/ /\\_/\\ + | ( -.-) + / \\ > ^ <''', ], frameIntervalMs: 220, ); -/// 마을/상점 애니메이션 (7줄) +/// 마을/상점 애니메이션 (심플 3줄 캐릭터) const townAnimation = AsciiAnimationData( frames: [ // 프레임 1: 상점 앞에서 대기 ''' - _______________ - / \\ O - | SHOP | /|\\ - | [=====] | / \\ - | | | | | - |___|_____|______| _|_ - ~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~''', + ___________ o + / SHOP \\/|\\ +~~|__|____|__|/ \\~~~~~~~~~~~~~''', // 프레임 2: 상점으로 이동 ''' - _______________ - / \\ O - | SHOP | /|\\ - | [=====] | / \\ - | | | | | - |___|_____|______| _|_ - ~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~''', - // 프레임 3: 상점 앞 도착 + ___________ o + / SHOP \\/|\\ +~~|__|____|__|/ \\~~~~~~~~~~~~~''', + // 프레임 3: 거래 시작 ''' - _______________ - / \\ O - | SHOP | /|\\ - | [=====] | / \\ - | | | | | - |___|_____|______| _|_ - ~~~~~~~~~~~~~~~~~~~~~~~~~~~''', + ___________ o \$ + / SHOP \\/|\\ \$ +~~|__[ @@ ]__|/ \\ \$~~~~~~~~~~~''', // 프레임 4: 거래 중 ''' - _______________ - / \\ O \$ - | SHOP | /|\\ \$ - | [=====] | /\\\$ - | | @ | | | - |___|_____|______| _|_ - ~~~~~~~~~~~~~~~~~~~~~~~~~~~''', + ___________ o \$\$ + / SHOP \\/|\\ \$\$ +~~|__[ @@ ]__|/ \\ \$\$~~~~~~~~~~''', // 프레임 5: 거래 완료 ''' - _______________ - / \\ \\O/ - | SHOP | | + - | [=====] | / \\ + - | | @ | | | + - |___|_____|______| _|_ - ~~~~~~~~~~~~~~~~~~~~~~~~~~~''', + ___________ \\o/ + + / SHOP \\ | + +~~|__[ @@ ]__|/ \\ +~~~~~~~~~~~''', ], frameIntervalMs: 280, ); -/// 걷는 애니메이션 (7줄, 배경 포함) +/// 걷는 애니메이션 (심플 3줄 캐릭터 + 배경) const walkingAnimation = AsciiAnimationData( frames: [ // 프레임 1: 서있기 ''' - O - /|\\ - / \\ - ~~ | ~~ - ~~~~ _|_ ~~~~ - ~~~~~~ ~~~~~~~~ ~~~~~~ - ~~~~~~~~~~~~~~~~~~~~~~~~~~~~''', + ~~~~ o ~~~~ + ~~~~~~ /|\\ ~~~~~~ +~~~~~~~~ / \\ ~~~~~~~~''', // 프레임 2: 왼발 앞 ''' - O - /|\\ - /| - ~~ / \\ ~~ - ~~~~ _|_ ~~~~ - ~~~~~~ ~~~~~~~~ ~~~~~~ - ~~~~~~~~~~~~~~~~~~~~~~~~~~~~''', + ~~~~ o ~~~~ + ~~~~~~ /|\\ ~~~~~~ +~~~~~~~~ /| ~~~~~~~~''', // 프레임 3: 이동 중 ''' - O - /|\\ - |\\ - ~~ / \\ ~~ - ~~~~ _|_ ~~~~ - ~~~~~~ ~~~~~~~~ ~~~~~~ - ~~~~~~~~~~~~~~~~~~~~~~~~~~~~''', + ~~~~ o ~~~~ + ~~~~~~ /|\\ ~~~~~~ +~~~~~~~~ |\\ ~~~~~~~~''', // 프레임 4: 오른발 앞 ''' - O - /|\\ - |/ - ~~ / \\ ~~ - ~~~~ _|_ ~~~~ - ~~~~~~ ~~~~~~~~ ~~~~~~ - ~~~~~~~~~~~~~~~~~~~~~~~~~~~~''', + ~~~~ o ~~~~ + ~~~~~~ /|\\ ~~~~~~ +~~~~~~~~ |/ ~~~~~~~~''', // 프레임 5: 복귀 ''' - O - /|\\ - / \\ - ~~ | ~~ - ~~~~ _|_ ~~~~ - ~~~~~~ ~~~~~~~~ ~~~~~~ - ~~~~~~~~~~~~~~~~~~~~~~~~~~~~''', + ~~~~ o ~~~~ + ~~~~~~ /|\\ ~~~~~~ +~~~~~~~~ / \\ ~~~~~~~~''', ], frameIntervalMs: 180, ); -/// 곤충 전투 애니메이션 +/// 곤충 전투 애니메이션 (심플 3줄) const battleAnimationInsect = AsciiAnimationData( frames: [ // 프레임 1: 대치 ''' - ,O, /\\_/\\ - /( )\\ ( o o ) - / \\ vs /|=====|\\ - _| |_ < | | > - | | \\|_____|/ - _| |_ / \\ - |_________| /_______\\''', + o vs /\\_/\\ + /|\\ ( o o ) + / \\ /|=====|\\''', // 프레임 2: 공격 준비 ''' - O /\\_/\\ - /|\\----o ( o o ) - / \\ /|=====|\\ - _| |_ < | | > - | | \\|_____|/ - _| |_ / \\ - |_________| /_______\\''', + o----o /\\_/\\ + /|\\ ( o o ) + / \\ /|=====|\\''', // 프레임 3: 공격 중 ''' - O o-->/\\_/\\ - /|\\----------> ( o o ) - / \\ /|=====|\\ - _| |_ < | | > - | | \\|_____|/ - _| |_ / \\ - |_________| /_______\\''', + o o-----> /\\_/\\ + /|\\ ( X X ) + / \\ /|=====|\\''', // 프레임 4: 히트 ''' - O /\\_/\\ - /|\\ **** ( X X ) **** - / \\ ** /|=====|\\ ** - _| |_ < | | > - | | \\|_____|/ - _| |_ / \\ - |_________| /_______\\''', + o **** /\\_/\\ + /|\\ *** ( X X ) *** + / \\ /|=====|\\''', // 프레임 5: 복귀 ''' - \\O/ /\\_/\\ - | ( - - ) - / \\ /|=====|\\ - _| |_ < | | > - | | \\|_____|/ - _| |_ / \\ - |_________| /_______\\''', + \\o/ /\\_/\\ + | ( - - ) + / \\ /|=====|\\''', ], frameIntervalMs: 220, ); -/// 인간형 전투 애니메이션 +/// 인간형 전투 애니메이션 (심플 3줄) const battleAnimationHumanoid = AsciiAnimationData( frames: [ // 프레임 1: 대치 ''' - ,O, O - /( )\\ /|\\ - / \\ vs / | \\ - _| |_ ___|___ - | | | | - _| |_ | orc | - |_________| |_______|''', + o vs O + /|\\ /|\\ + / \\ / | \\''', // 프레임 2: 공격 준비 ''' - O O - /|\\----o /|\\ - / \\ / | \\ - _| |_ ___|___ - | | | | - _| |_ | orc | - |_________| |_______|''', + o----o O + /|\\ /|\\ + / \\ / | \\''', // 프레임 3: 공격 중 ''' - O o----> O - /|\\-----------> /|\\ - / \\ / | \\ - _| |_ ___|___ - | | | | - _| |_ | orc | - |_________| |_______|''', + o o-----> O + /|\\ X|X + / \\ / | \\''', // 프레임 4: 히트 ''' - O O - /|\\ **** X|X **** - / \\ ** / | \\ ** - _| |_ ___|___ - | | | | - _| |_ | orc | - |_________| |_______|''', + o **** O + /|\\ *** X|X *** + / \\ / | \\''', // 프레임 5: 복귀 ''' - \\O/ O - | /|\\ - / \\ / | \\ - _| |_ ___|___ - | | | | - _| |_ | orc | - |_________| |_______|''', + \\o/ O + | /|\\ + / \\ / | \\''', ], frameIntervalMs: 220, ); -/// 언데드 전투 애니메이션 +/// 언데드 전투 애니메이션 (심플 3줄) const battleAnimationUndead = AsciiAnimationData( frames: [ // 프레임 1: 대치 ''' - ,O, .-. - /( )\\ (o.o) - / \\ vs |=| - _| |_ /|X|\\ - | | / | | \\ - _| |_ \\_|_|_/ - |_________| _/ \\_''', + o vs .-. + /|\\ (o.o) + / \\ |=|''', // 프레임 2: 공격 준비 ''' - O .-. - /|\\----o (o.o) - / \\ |=| - _| |_ /|X|\\ - | | / | | \\ - _| |_ \\_|_|_/ - |_________| _/ \\_''', + o----o .-. + /|\\ (o.o) + / \\ |=|''', // 프레임 3: 공격 중 ''' - O o--->.-. - /|\\-----------> (o.o) - / \\ |=| - _| |_ /|X|\\ - | | / | | \\ - _| |_ \\_|_|_/ - |_________| _/ \\_''', + o o-----> .-. + /|\\ (X.X) + / \\ |=|''', // 프레임 4: 히트 ''' - O .-. - /|\\ **** (X.X) **** - / \\ ** |=| ** - _| |_ /|X|\\ - | | / | | \\ - _| |_ \\_|_|_/ - |_________| _/ \\_''', + o **** .-. + /|\\ *** (X.X) *** + / \\ |~|''', // 프레임 5: 복귀 ''' - \\O/ .-. - | (-.-) - / \\ |=| - _| |_ /|X|\\ - | | / | | \\ - _| |_ \\_|_|_/ - |_________| _/ \\_''', + \\o/ .-. + | (-.-) + / \\ |=|''', ], frameIntervalMs: 250, ); -/// 드래곤 전투 애니메이션 +/// 드래곤 전투 애니메이션 (심플 3줄) const battleAnimationDragon = AsciiAnimationData( frames: [ // 프레임 1: 대치 ''' - ,O, __/\\__ - /( )\\ / \\ - / \\ vs < (O)(O) > - _| |_ \\ \\/ / - | | \\ / - _| |_ /|\\~~~/|\\ - |_________| /_________\\''', + o vs __/\\__ + /|\\ < (O)(O) > + / \\ \\ \\/ /''', // 프레임 2: 공격 준비 ''' - O __/\\__ - /|\\----o / \\ - / \\ < (O)(O) > - _| |_ \\ \\/ / - | | \\ / - _| |_ /|\\~~~/|\\ - |_________| /_________\\''', + o----o __/\\__ + /|\\ < (O)(O) > + / \\ \\ \\/ /''', // 프레임 3: 공격 중 ''' - O o--->__/\\__ - /|\\---------> / \\ - / \\ < (O)(O) > - _| |_ \\ \\/ / - | | \\ / - _| |_ /|\\~~~/|\\ - |_________| /_________\\''', + o o-----> __/\\__ + /|\\ < (X)(X) > + / \\ \\ \\/ /''', // 프레임 4: 히트 ''' - O __/\\__ - /|\\ **** / >< \\ **** - / \\ ** < (X)(X) > ** - _| |_ \\ \\/ / - | | \\ / - _| |_ /|\\~~~/|\\ - |_________| /_________\\''', + o **** __/\\__ + /|\\ *** < (X)(X) > *** + / \\ \\ ~~ /''', // 프레임 5: 복귀 ''' - \\O/ __/\\__ - | / \\ - / \\ < (-)(-)> - _| |_ \\ \\/ / - | | \\ / - _| |_ /|\\~~~/|\\ - |_________| /_________\\''', + \\o/ __/\\__ + | < (-)(-)> + / \\ \\ \\/ /''', ], frameIntervalMs: 200, ); -/// 슬라임 전투 애니메이션 +/// 슬라임 전투 애니메이션 (심플 3줄) const battleAnimationSlime = AsciiAnimationData( frames: [ // 프레임 1: 대치 ''' - ,O, .---. - /( )\\ / \\ - / \\ vs ( o o ) - _| |_ \\ ~ / - | | '---' - _| |_ ~~~~~~~ - |_________| ~~~~~~~~~''', + o vs .---. + /|\\ ( o o ) + / \\ ~~~~~''', // 프레임 2: 공격 준비 ''' - O .---. - /|\\----o / \\ - / \\ ( o o ) - _| |_ \\ ~ / - | | '---' - _| |_ ~~~~~~~ - |_________| ~~~~~~~~~''', + o----o .---. + /|\\ ( o o ) + / \\ ~~~~~''', // 프레임 3: 공격 중 ''' - O o--->.---. - /|\\---------> / \\ - / \\ ( o o ) - _| |_ \\ ~ / - | | '---' - _| |_ ~~~~~~~ - |_________| ~~~~~~~~~''', + o o-----> .---. + /|\\ ( X X ) + / \\ ~~~~~''', // 프레임 4: 히트 ''' - O .---. - /|\\ **** / X X \\ **** - / \\ ** ( ~ ) ** - _| |_ \\ / - | | '---' - _| |_ ~~~~~~~ - |_________| ~~~~~~~~~''', + o **** .---. + /|\\ *** ( X X ) *** + / \\ ~~~~~''', // 프레임 5: 복귀 ''' - \\O/ .---. - | / \\ - / \\ ( - - ) - _| |_ \\ ~ / - | | '---' - _| |_ ~~~~~~~ - |_________| ~~~~~~~~~''', + \\o/ .---. + | ( - - ) + / \\ ~~~~~''', ], frameIntervalMs: 280, ); -/// 악마 전투 애니메이션 +/// 악마 전투 애니메이션 (심플 3줄) const battleAnimationDemon = AsciiAnimationData( frames: [ // 프레임 1: 대치 ''' - ,O, /\\ /\\ - /( )\\ ( \\ / ) - / \\ vs \\ o o / - _| |_ | V | - | | | ~~~ | - _| |_ /| |\\ - |_________| /___|___|_\\''', + o vs /\\ /\\ + /|\\ ( o V o ) + / \\ \\ ~~~ /''', // 프레임 2: 공격 준비 ''' - O /\\ /\\ - /|\\----o ( \\ / ) - / \\ \\ o o / - _| |_ | V | - | | | ~~~ | - _| |_ /| |\\ - |_________| /___|___|_\\''', + o----o /\\ /\\ + /|\\ ( o V o ) + / \\ \\ ~~~ /''', // 프레임 3: 공격 중 ''' - O o--->/\\ /\\ - /|\\--------> ( \\ / ) - / \\ \\ o o / - _| |_ | V | - | | | ~~~ | - _| |_ /| |\\ - |_________| /___|___|_\\''', + o o-----> /\\ /\\ + /|\\ ( X V X ) + / \\ \\ ~~~ /''', // 프레임 4: 히트 ''' - O /\\ /\\ - /|\\ **** ( X X ) **** - / \\ ** \\ X X / ** - _| |_ | V | - | | | ~~~ | - _| |_ /| |\\ - |_________| /___|___|_\\''', + o **** /\\ /\\ + /|\\ *** ( X V X ) *** + / \\ \\ ~~~ /''', // 프레임 5: 복귀 ''' - \\O/ /\\ /\\ - | ( \\ / ) - / \\ \\ - - / - _| |_ | V | - | | | ~~~ | - _| |_ /| |\\ - |_________| /___|___|_\\''', + \\o/ /\\ /\\ + | ( - V - ) + / \\ \\ ~~~ /''', ], frameIntervalMs: 200, ); diff --git a/lib/src/core/animation/background_data.dart b/lib/src/core/animation/background_data.dart new file mode 100644 index 0000000..ec937f0 --- /dev/null +++ b/lib/src/core/animation/background_data.dart @@ -0,0 +1,178 @@ +// 환경별 배경 패턴 데이터 +// ASCII Patrol 스타일 - 패럴렉스 스크롤링 배경 + +import 'package:askiineverdie/src/core/animation/background_layer.dart'; + +/// 환경별 배경 레이어 반환 +List getBackgroundLayers(EnvironmentType environment) { + return switch (environment) { + EnvironmentType.town => _townLayers, + EnvironmentType.forest => _forestLayers, + EnvironmentType.cave => _caveLayers, + EnvironmentType.dungeon => _dungeonLayers, + EnvironmentType.tech => _techLayers, + EnvironmentType.void_ => _voidLayers, + }; +} + +// ============================================================================ +// 마을 (Town) - 건물 실루엣 +// ============================================================================ +const _townLayers = [ + // 원경 - 하늘/별 + BackgroundLayer( + lines: [r'. * . * . * . * . * . * '], + scrollSpeed: 0.05, + yStart: 0, + ), + // 중경 - 건물 실루엣 + BackgroundLayer( + lines: [ + r' _|__|_ _|__|_ _|__|_ ', + r' | | | | | | ', + ], + scrollSpeed: 0.15, + yStart: 1, + ), + // 전경 - 바닥 + BackgroundLayer( + lines: [r'====[]====[]====[]====[]====[]====[]'], + scrollSpeed: 0.3, + yStart: 7, + ), +]; + +// ============================================================================ +// 숲 (Forest) - 나무 +// ============================================================================ +const _forestLayers = [ + // 원경 - 하늘/별 + BackgroundLayer( + lines: [r'. * . * . * . * . * '], + scrollSpeed: 0.05, + yStart: 0, + ), + // 중경 - 나무 실루엣 + BackgroundLayer( + lines: [ + r' ,@@@, ,@@, ,@@@', + r' @@ @@ @@ @@ @@ ', + ], + scrollSpeed: 0.15, + yStart: 1, + ), + // 전경 - 풀/바닥 + BackgroundLayer( + lines: [r'____||____||____||____||____||____||'], + scrollSpeed: 0.3, + yStart: 7, + ), +]; + +// ============================================================================ +// 동굴 (Cave) - 바위 +// ============================================================================ +const _caveLayers = [ + // 천장 + BackgroundLayer( + lines: [r'vvVVvvVVvvVVvvVVvvVVvvVVvvVVvvVVvvVV'], + scrollSpeed: 0.1, + yStart: 0, + ), + // 종유석 + BackgroundLayer( + lines: [ + r' | V | V | ', + r' V V V ', + ], + scrollSpeed: 0.15, + yStart: 1, + ), + // 바닥 - 석순 + BackgroundLayer( + lines: [r'^__/\__^__/\__^__/\__^__/\__^__/\__^'], + scrollSpeed: 0.25, + yStart: 7, + ), +]; + +// ============================================================================ +// 던전 (Dungeon) - 벽돌 +// ============================================================================ +const _dungeonLayers = [ + // 천장 - 벽돌 + BackgroundLayer( + lines: [r'####|####|####|####|####|####|####|#'], + scrollSpeed: 0.1, + yStart: 0, + ), + // 횃불 + BackgroundLayer( + lines: [ + r' * * * ', + r' )| )| )| ', + ], + scrollSpeed: 0.15, + yStart: 1, + ), + // 바닥 - 타일 + BackgroundLayer( + lines: [r'====[]====[]====[]====[]====[]====[]'], + scrollSpeed: 0.25, + yStart: 7, + ), +]; + +// ============================================================================ +// 기술 (Tech) - 회로 +// ============================================================================ +const _techLayers = [ + // 상단 - 회로 + BackgroundLayer( + lines: [r'-+-+-+-||-+-+-+-||-+-+-+-||-+-+-+-||'], + scrollSpeed: 0.1, + yStart: 0, + ), + // 데이터 스트림 + BackgroundLayer( + lines: [ + r' 10110 01101 10110 01101 101', + r' 01 10 01 10 ', + ], + scrollSpeed: 0.2, + yStart: 2, + ), + // 바닥 - 패널 + BackgroundLayer( + lines: [r'[====][====][====][====][====][====]'], + scrollSpeed: 0.3, + yStart: 7, + ), +]; + +// ============================================================================ +// 보이드 (Void) - 별/공허 +// ============================================================================ +const _voidLayers = [ + // 별 + BackgroundLayer( + lines: [r' * . * . * . * . * '], + scrollSpeed: 0.03, + yStart: 0, + ), + // 은하 + BackgroundLayer( + lines: [ + r' ~*~ ~*~ ~*~ ', + r' *~ ~* *~ ~* *~ ~*', + ], + scrollSpeed: 0.08, + yStart: 2, + ), + // 심연 + BackgroundLayer( + lines: [r'~.~.~.~.~.~.~.~.~.~.~.~.~.~.~.~.~.~.'], + scrollSpeed: 0.15, + yStart: 7, + ), +]; diff --git a/lib/src/core/animation/background_layer.dart b/lib/src/core/animation/background_layer.dart new file mode 100644 index 0000000..6ae7e9b --- /dev/null +++ b/lib/src/core/animation/background_layer.dart @@ -0,0 +1,92 @@ +// 배경 레이어 시스템 (ASCII Patrol 스타일 패럴렉스) +// 각 환경은 여러 레이어로 구성되며, 레이어마다 다른 스크롤 속도를 가짐 + +/// 배경 레이어 데이터 +class BackgroundLayer { + const BackgroundLayer({ + required this.lines, + required this.scrollSpeed, + this.yStart = 0, + }); + + /// 레이어 패턴 (각 줄은 반복 가능한 패턴) + final List lines; + + /// 스크롤 속도 (0.0 = 정지, 1.0 = 최고속) + /// 원경일수록 느리게, 전경일수록 빠르게 + final double scrollSpeed; + + /// 시작 Y 위치 (0~7) + final int yStart; +} + +/// 환경 타입 +enum EnvironmentType { + /// 마을 - 건물 실루엣 + town, + + /// 숲 - 나무 + forest, + + /// 동굴 - 바위 + cave, + + /// 던전 - 벽돌 + dungeon, + + /// 기술 - 회로 + tech, + + /// 보이드 - 별/공허 (보스) + void_, +} + +/// TaskType과 몬스터 이름에서 환경 타입 추론 +EnvironmentType inferEnvironment(String? taskType, String? monsterName) { + // 마을 관련 태스크 + if (taskType == 'heading' || taskType == 'buyEquip') { + return EnvironmentType.town; + } + + // 몬스터 이름에서 환경 추론 + if (monsterName != null) { + final lower = monsterName.toLowerCase(); + + // 보이드/우주 + if (lower.contains('void') || + lower.contains('cosmic') || + lower.contains('star') || + lower.contains('galaxy')) { + return EnvironmentType.void_; + } + + // 기술/사이버 + if (lower.contains('cyber') || + lower.contains('robot') || + lower.contains('ai') || + lower.contains('data') || + lower.contains('server')) { + return EnvironmentType.tech; + } + + // 언데드/던전 + if (lower.contains('zombie') || + lower.contains('skeleton') || + lower.contains('ghost') || + lower.contains('undead') || + lower.contains('dungeon')) { + return EnvironmentType.dungeon; + } + + // 동굴 + if (lower.contains('cave') || + lower.contains('bat') || + lower.contains('spider') || + lower.contains('worm')) { + return EnvironmentType.cave; + } + } + + // 기본: 숲 + return EnvironmentType.forest; +} diff --git a/lib/src/core/animation/battle_composer.dart b/lib/src/core/animation/battle_composer.dart new file mode 100644 index 0000000..29a8b79 --- /dev/null +++ b/lib/src/core/animation/battle_composer.dart @@ -0,0 +1,713 @@ +// BattleComposer - 전투 프레임 실시간 합성 +// Stone Story RPG 스타일 참고 - 8줄 캐릭터/몬스터, 60자 폭 +// ASCII Patrol 스타일 패럴렉스 배경 + +import 'package:askiineverdie/src/core/animation/ascii_animation_data.dart'; +import 'package:askiineverdie/src/core/animation/background_data.dart'; +import 'package:askiineverdie/src/core/animation/background_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/weapon_category.dart'; +import 'package:askiineverdie/src/core/animation/weapon_effects.dart'; + +/// 전투 프레임 합성기 +class BattleComposer { + const BattleComposer({ + required this.weaponCategory, + required this.hasShield, + required this.monsterCategory, + required this.monsterSize, + }); + + final WeaponCategory weaponCategory; + final bool hasShield; + final MonsterCategory monsterCategory; + final MonsterSize monsterSize; + + /// 전체 프레임 폭 (문자 수) + static const int frameWidth = 60; + + /// 프레임 높이 (줄 수) + static const int frameHeight = 8; + + /// 영역 분할 + static const int characterWidth = 18; + static const int effectWidth = 24; + static const int monsterWidth = 18; + + /// 전투 프레임 생성 (배경 없음) + String composeFrame(BattlePhase phase, int subFrame, String? monsterBaseName) { + // 캐릭터 프레임 + var charFrame = getCharacterFrame(phase, subFrame); + if (hasShield) { + charFrame = charFrame.withShield(); + } + + // 몬스터 프레임 (애니메이션 포함) + final monsterFrames = + _getAnimatedMonsterFrames(monsterCategory, monsterSize, phase); + final monsterFrame = monsterFrames[subFrame % monsterFrames.length]; + + // 무기 이펙트 (단일 라인) + final effect = getWeaponEffect(weaponCategory); + final effectLine = _getEffectLine(effect, phase, subFrame); + + // 프레임 합성 + return _compose(charFrame.lines, monsterFrame, effectLine, phase); + } + + /// 전투 프레임 생성 (배경 포함, ASCII Patrol 스타일) + String composeFrameWithBackground( + BattlePhase phase, + int subFrame, + String? monsterBaseName, + EnvironmentType environment, + int globalTick, + ) { + // 1. 8x60 캔버스 생성 (공백으로 초기화) + final canvas = + List.generate(frameHeight, (_) => List.filled(frameWidth, ' ')); + + // 2. 배경 레이어 그리기 (뒤에서 앞으로) + final layers = getBackgroundLayers(environment); + for (final layer in layers) { + _drawBackgroundLayer(canvas, layer, globalTick); + } + + // 3. 캐릭터 프레임 (정규화하여 왼쪽 정렬) + var charFrame = getCharacterFrame(phase, subFrame); + if (hasShield) { + charFrame = charFrame.withShield(); + } + final normalizedChar = _normalizeSprite(charFrame.lines, characterWidth); + final charY = frameHeight - normalizedChar.length; + _overlaySpriteWithSpaces(canvas, normalizedChar, 0, charY); + + // 4. 몬스터 프레임 (정규화하여 오른쪽 정렬) + final monsterFrames = + _getAnimatedMonsterFrames(monsterCategory, monsterSize, phase); + final monsterFrame = monsterFrames[subFrame % monsterFrames.length]; + final normalizedMonster = _normalizeSpriteRight(monsterFrame, monsterWidth); + final monsterX = frameWidth - monsterWidth; + final monsterY = frameHeight - normalizedMonster.length; + _overlaySpriteWithSpaces(canvas, normalizedMonster, monsterX, monsterY); + + // 5. 멀티라인 이펙트 오버레이 (공격/히트/준비 페이즈) + if (phase == BattlePhase.prepare || + 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; + for (var i = 0; i < effectLines.length; i++) { + final y = effectY + i; + if (y >= 0 && y < frameHeight && effectLines[i].isNotEmpty) { + _overlayText(canvas, effectLines[i], characterWidth, y); + } + } + } + } + + // 6. 문자열로 변환 + return canvas.map((row) => row.join()).join('\n'); + } + + /// 스프라이트를 지정 폭으로 정규화 (왼쪽 정렬) + List _normalizeSprite(List sprite, int width) { + return sprite.map((line) => line.padRight(width).substring(0, width)).toList(); + } + + /// 스프라이트를 지정 폭으로 정규화 (오른쪽 정렬) + List _normalizeSpriteRight(List sprite, int width) { + return sprite.map((line) { + final trimmed = line.trimRight(); + if (trimmed.length >= width) return trimmed.substring(0, width); + return trimmed.padLeft(width); + }).toList(); + } + + /// 스프라이트를 캔버스에 오버레이 (공백도 덮어쓰기 - Z-order용) + void _overlaySpriteWithSpaces( + List> canvas, + List 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]; + for (var j = 0; j < line.length; j++) { + final x = startX + j; + if (x < 0 || x >= frameWidth) continue; + + final char = line[j]; + // 공백이 아닌 문자만 덮어쓰기 (투명 배경 효과) + if (char != ' ') { + canvas[y][x] = char; + } + } + } + } + + /// 배경 레이어를 캔버스에 그리기 + void _drawBackgroundLayer( + List> canvas, + BackgroundLayer layer, + int globalTick, + ) { + for (var i = 0; i < layer.lines.length; i++) { + final y = layer.yStart + i; + if (y >= frameHeight) break; + + final pattern = layer.lines[i]; + if (pattern.isEmpty) continue; + + // 스크롤 오프셋 계산 + final offset = (globalTick * layer.scrollSpeed).toInt() % pattern.length; + + // 패턴을 스크롤하며 그리기 + for (var x = 0; x < frameWidth; x++) { + final patternIdx = (x + offset) % pattern.length; + final char = pattern[patternIdx]; + if (char != ' ') { + canvas[y][x] = char; + } + } + } + } + + /// 텍스트를 캔버스에 오버레이 + void _overlayText( + List> canvas, + String text, + int startX, + int y, + ) { + if (y < 0 || y >= frameHeight) return; + + for (var i = 0; i < text.length; i++) { + final x = startX + i; + if (x < 0 || x >= frameWidth) continue; + + final char = text[i]; + if (char != ' ') { + canvas[y][x] = char; + } + } + } + + /// 멀티라인 이펙트 프레임 반환 + List _getEffectLines( + WeaponEffect effect, BattlePhase phase, int subFrame) { + final frames = switch (phase) { + BattlePhase.idle => >[], + BattlePhase.prepare => effect.prepareFrames, + BattlePhase.attack => effect.attackFrames, + BattlePhase.hit => effect.hitFrames, + BattlePhase.recover => >[], + }; + if (frames.isEmpty) return []; + return frames[subFrame % frames.length]; + } + + /// 단일 라인 이펙트 (하위 호환용) + String _getEffectLine(WeaponEffect effect, BattlePhase phase, int subFrame) { + final lines = _getEffectLines(effect, phase, subFrame); + if (lines.isEmpty) return ''; + // 멀티라인 중 중간 라인 반환 (메인 이펙트) + final midIndex = lines.length ~/ 2; + return lines.length > midIndex ? lines[midIndex] : lines.first; + } + + String _compose( + List charLines, + List monsterLines, + String effectLine, + BattlePhase phase, + ) { + final result = []; + + // 캐릭터와 몬스터를 하단 정렬 (8줄 기준) + final charOffset = frameHeight - charLines.length; + final monsterOffset = frameHeight - monsterLines.length; + + // 이펙트 Y 위치: 캐릭터 body/arm 줄 (charOffset + 1) + final effectRow = charOffset + 1; + + for (var i = 0; i < frameHeight; i++) { + // 캐릭터 파트 (왼쪽 18자) + final charIdx = i - charOffset; + final charPart = + (charIdx >= 0 && charIdx < charLines.length ? charLines[charIdx] : '') + .padRight(characterWidth); + + // 이펙트 파트 (중앙 24자) - 캐릭터 팔 높이에 표시 + String effectPart = ''; + if (i == effectRow && + (phase == BattlePhase.attack || phase == BattlePhase.hit)) { + effectPart = effectLine; + } + effectPart = effectPart.padRight(effectWidth); + + // 몬스터 파트 (오른쪽 18자) + final monsterIdx = i - monsterOffset; + final monsterPart = (monsterIdx >= 0 && monsterIdx < monsterLines.length + ? monsterLines[monsterIdx] + : '') + .padLeft(monsterWidth); + + result.add('$charPart$effectPart$monsterPart'); + } + + return result.join('\n'); + } +} + +// ============================================================================ +// 몬스터 애니메이션 프레임 +// ============================================================================ + +/// 몬스터 애니메이션 프레임 반환 (페이즈별 다른 동작) +List> _getAnimatedMonsterFrames( + MonsterCategory category, + MonsterSize size, + BattlePhase phase, +) { + // 피격 상태 + if (phase == BattlePhase.hit) { + return _getMonsterHitFrames(category, size); + } + // 경계 상태 (prepare, attack) + if (phase == BattlePhase.prepare || phase == BattlePhase.attack) { + return _getMonsterAlertFrames(category, size); + } + // 일반 상태 (idle, recover) + return _getMonsterIdleFrames(category, size); +} + +/// 일반 상태 몬스터 프레임 +List> _getMonsterIdleFrames(MonsterCategory category, MonsterSize size) { + return switch (size) { + MonsterSize.tiny => _tinyIdleFrames(category), + MonsterSize.small => _smallIdleFrames(category), + MonsterSize.medium => _mediumIdleFrames(category), + MonsterSize.large => _largeIdleFrames(category), + _ => _hugeIdleFrames(category), // huge 이상은 같은 프레임 사용 + }; +} + +/// 피격 상태 몬스터 프레임 +List> _getMonsterHitFrames(MonsterCategory category, MonsterSize size) { + return switch (size) { + MonsterSize.tiny => _tinyHitFrames(category), + MonsterSize.small => _smallHitFrames(category), + MonsterSize.medium => _mediumHitFrames(category), + MonsterSize.large => _largeHitFrames(category), + _ => _hugeHitFrames(category), + }; +} + +/// 경계 상태 몬스터 프레임 (prepare/attack 시) +List> _getMonsterAlertFrames(MonsterCategory category, MonsterSize size) { + return switch (size) { + MonsterSize.tiny => _tinyAlertFrames(category), + MonsterSize.small => _smallAlertFrames(category), + MonsterSize.medium => _mediumAlertFrames(category), + MonsterSize.large => _largeAlertFrames(category), + _ => _hugeAlertFrames(category), + }; +} + +// ============================================================================ +// Tiny 몬스터 (2줄, 8줄 캔버스 하단 정렬) +// ============================================================================ + +List> _tinyIdleFrames(MonsterCategory category) { + return switch (category) { + MonsterCategory.beast => [ + [r'*', r'/\'], + [r'o', r'\/'], + ], + MonsterCategory.insect => [ + [r'><', r'\/'], + [r'<>', r'/\'], + ], + MonsterCategory.humanoid => [ + [r'o', r'|'], + [r'O', r'|'], + ], + MonsterCategory.undead => [ + [r'+', r'|'], + [r'x', r'|'], + ], + MonsterCategory.dragon => [ + [r'~<', r'>>'], + [r'<~', r'<<'], + ], + MonsterCategory.slime => [ + [r'()', r''], + [r'{}', r''], + ], + MonsterCategory.demon => [ + [r'^v', r'\/'], + [r'v^', r'/\'], + ], + }; +} + +List> _tinyHitFrames(MonsterCategory category) { + return [ + [r'*!', r'><'], + [r'!*', r'<>'], + ]; +} + +List> _tinyAlertFrames(MonsterCategory category) { + return switch (category) { + MonsterCategory.beast => [ + [r'!!', r'/\'], + [r'OO', r'><'], + ], + MonsterCategory.insect => [ + [r'!!', r'\/'], + [r'@@', r'/\'], + ], + MonsterCategory.humanoid => [ + [r'O!', r'|'], + [r'!O', r'X'], + ], + MonsterCategory.undead => [ + [r'!!', r'X'], + [r'@@', r'|'], + ], + MonsterCategory.dragon => [ + [r'!<', r'>>'], + [r'>!', r'<<'], + ], + MonsterCategory.slime => [ + [r'(!)', r''], + [r'{!}', r''], + ], + MonsterCategory.demon => [ + [r'^!', r'><'], + [r'!^', r'<>'], + ], + }; +} + +// ============================================================================ +// Small 몬스터 (4줄) +// ============================================================================ + +List> _smallIdleFrames(MonsterCategory category) { + return switch (category) { + MonsterCategory.beast => [ + [r' /\_/\', r'( o.o )', r' > ^ <', r' /| |\'], + [r' /\_/\', r'( o o )', r' > v <', r' \| |/'], + ], + MonsterCategory.insect => [ + [r' /\/\', r' (O O)', r' / \', r' \/ \/'], + [r' \/\/\', r' (O O)', r' \ /', r' /\ /\'], + ], + MonsterCategory.humanoid => [ + [r' O', r' /|\', r' / \', r' _| |_'], + [r' O', r' \|/', r' | |', r' _/ \_'], + ], + MonsterCategory.undead => [ + [r' _+_', r' (x_x)', r' /|\', r' _/ \_'], + [r' _+_', r' (X_X)', r' \|/', r' _| |_'], + ], + MonsterCategory.dragon => [ + [r' __', r' <(oo)~', r' / \', r' <_ _>'], + [r' __', r' (oo)>', r' \ /', r' <_ _>'], + ], + MonsterCategory.slime => [ + [r' ___', r' ( )', r' ( )', r' \_/'], + [r' _', r' / \', r' { }', r' \_/'], + ], + MonsterCategory.demon => [ + [r' ^w^', r' (|o|)', r' /|\', r' V V'], + [r' ^W^', r' (|O|)', r' \|/', r' v v'], + ], + }; +} + +List> _smallHitFrames(MonsterCategory category) { + return [ + [r' *!*', r' (>_<)', r' \X/', r' _/_\_'], + [r' !*!', r' (@_@)', r' /X\', r' _\_/_'], + ]; +} + +List> _smallAlertFrames(MonsterCategory category) { + return switch (category) { + MonsterCategory.beast => [ + [r' /\_/\', r'( O!O )', r' > ! <', r' /| |\'], + [r' /\_/\', r'( !O! )', r' > ! <', r' \| |/'], + ], + MonsterCategory.insect => [ + [r' /\/\', r' (! !)', r' / \', r' \/ \/'], + [r' \/\/\', r' (! !)', r' \ /', r' /\ /\'], + ], + MonsterCategory.humanoid => [ + [r' O!', r' /|\', r' / \', r' _| |_'], + [r' !O', r' \|/', r' | |', r' _/ \_'], + ], + MonsterCategory.undead => [ + [r' _!_', r' (!_!)', r' /|\', r' _/ \_'], + [r' _!_', r' (!_!)', r' \|/', r' _| |_'], + ], + MonsterCategory.dragon => [ + [r' __', r' <(!!)~', r' / \', r' <_ _>'], + [r' __', r' (!!)>', r' \ /', r' <_ _>'], + ], + MonsterCategory.slime => [ + [r' ___', r' ( ! )', r' ( ! )', r' \_/'], + [r' _', r' /!\', r' { ! }', r' \_/'], + ], + MonsterCategory.demon => [ + [r' ^!^', r' (|!|)', r' /|\', r' V V'], + [r' ^!^', r' (|!|)', r' \|/', r' v v'], + ], + }; +} + +// ============================================================================ +// Medium 몬스터 (6줄) +// ============================================================================ + +List> _mediumIdleFrames(MonsterCategory category) { + return switch (category) { + MonsterCategory.beast => [ + [r' /\_/\', r' ( O.O )', r' > ^ <', r' /| |\', r' | | | |', r'_|_| |_|_'], + [r' /\_/\', r' ( O O )', r' > v <', r' \| |/', r' | | | |', r'_|_| |_|_'], + ], + MonsterCategory.insect => [ + [r' /\/\', r' /O O\', r' \ /', r' / \', r' \/ \/', r' _/ \_'], + [r' \/\/\', r' \O O/', r' / \', r' \ /', r' /\ /\', r' _\ /_'], + ], + MonsterCategory.humanoid => [ + [r' O', r' /|\', r' / \', r' | |', r' | |', r' _| |_'], + [r' O', r' \|/', r' | |', r' | |', r' | |', r' _/ \_'], + ], + MonsterCategory.undead => [ + [r' _+_', r' (X_X)', r' /|\', r' / | \', r' | | |', r'_/ | \_'], + [r' _x_', r' (x_x)', r' \|/', r' \ | /', r' | | |', r'_\ | /_'], + ], + MonsterCategory.dragon => [ + [r' __', r' <(OO)~', r' / \', r' / \', r' | |', r'<__ __>'], + [r' __', r' (OO)>', r' \ /', r' \ /', r' | |', r'<__ __>'], + ], + MonsterCategory.slime => [ + [r' ____', r' / \', r' ( )', r' ( )', r' \ /', r' \__/'], + [r' __', r' / \', r' / \', r' { }', r' \ /', r' \__/'], + ], + MonsterCategory.demon => [ + [r' ^W^', r' (|O|)', r' /|\', r' / | \', r' V V', r' _/ \_'], + [r' ^w^', r' (|o|)', r' \|/', r' \ | /', r' v v', r' _\ /_'], + ], + }; +} + +List> _mediumHitFrames(MonsterCategory category) { + return [ + [r' *!*', r' (>.<)', r' \X/', r' / \', r' | |', r'_/_ \_\'], + [r' !*!', r' (@_@)', r' /X\', r' \ /', r' | |', r'_\_ /_/'], + ]; +} + +List> _mediumAlertFrames(MonsterCategory category) { + return switch (category) { + MonsterCategory.beast => [ + [r' /\_/\', r' ( O!O )', r' > ! <', r' /| |\', r' | | | |', r'_|_| |_|_'], + [r' /\_/\', r' ( !O! )', r' > ! <', r' \| |/', r' | | | |', r'_|_| |_|_'], + ], + MonsterCategory.insect => [ + [r' /\/\', r' /! !\', r' \ /', r' / \', r' \/ \/', r' _/ \_'], + [r' \/\/\', r' \! !/', r' / \', r' \ /', r' /\ /\', r' _\ /_'], + ], + MonsterCategory.humanoid => [ + [r' O!', r' /|\', r' / \', r' | |', r' | |', r' _| |_'], + [r' !O', r' \|/', r' | |', r' | |', r' | |', r' _/ \_'], + ], + MonsterCategory.undead => [ + [r' _!_', r' (!_!)', r' /|\', r' / | \', r' | | |', r'_/ | \_'], + [r' _!_', r' (!_!)', r' \|/', r' \ | /', r' | | |', r'_\ | /_'], + ], + MonsterCategory.dragon => [ + [r' __', r' <(!!)~', r' / \', r' / \', r' | |', r'<__ __>'], + [r' __', r' (!!)>', r' \ /', r' \ /', r' | |', r'<__ __>'], + ], + MonsterCategory.slime => [ + [r' ____', r' / ! \', r' ( ! )', r' ( ! )', r' \ /', r' \__/'], + [r' __', r' / !\', r' / ! \', r' { ! }', r' \ /', r' \__/'], + ], + MonsterCategory.demon => [ + [r' ^!^', r' (|!|)', r' /|\', r' / | \', r' V V', r' _/ \_'], + [r' ^!^', r' (|!|)', r' \|/', r' \ | /', r' v v', r' _\ /_'], + ], + }; +} + +// ============================================================================ +// Large 몬스터 (8줄) +// ============================================================================ + +List> _largeIdleFrames(MonsterCategory category) { + return switch (category) { + MonsterCategory.beast => [ + [r' /\__/\', r' ( O O )', r' > ^^ <', r' /| |\', r' | | | |', r' | | | |', r'_| | | |_', r'|__|____|__|'], + [r' /\__/\', r' ( O O )', r' > vv <', r' \| |/', r' | | | |', r' | | | |', r'_| | | |_', r'|__|____|__|'], + ], + MonsterCategory.insect => [ + [r' /\/\/\', r' /O O\', r' \ /', r' / \', r' \/ \/', r' | \ / |', r' _| \/ |_', r'|___________|'], + [r' \/\/\/\', r' \O O/', r' / \', r' \ /', r' /\ /\', r' | / \ |', r' _| /\ |_', r'|___________|'], + ], + MonsterCategory.humanoid => [ + [r' O', r' /|\', r' / \', r' | |', r' | |', r' | |', r' _| |_', r'|_________|'], + [r' O', r' \|/', r' | |', r' | |', r' | |', r' | |', r' _/ \_', r'|_________|'], + ], + MonsterCategory.undead => [ + [r' _/+\_', r' (X___X)', r' /|||\', r' / ||| \', r' | ||| |', r' | / \ |', r' _|/ \|_', r'|_/ \_|'], + [r' _\+/_', r' (x___x)', r' \|||/', r' \ ||| /', r' | ||| |', r' | \ / |', r' _|\ /|_', r'|_\ /_|'], + ], + MonsterCategory.dragon => [ + [r' ___', r' <<(O O)~~', r' / || \', r' / || \', r' | || |', r' | || |', r' _| || |_', r'<___________|>'], + [r' ___', r' (O O)>>', r' \ || /', r' \ || /', r' | || |', r' | || |', r' _| || |_', r'<___________|>'], + ], + MonsterCategory.slime => [ + [r' _____', r' / \', r' / \', r' ( )', r' ( )', r' \ /', r' \_____/', r' \___/'], + [r' ___', r' / \', r' / \', r' { }', r' { }', r' \ /', r' \___/', r' \_/'], + ], + MonsterCategory.demon => [ + [r' ^W^', r' /|O|\', r' /|\', r' / | \', r' | | |', r' V | V', r' _/ | \_', r'|____|____|'], + [r' ^w^', r' \|o|/', r' \|/', r' \ | /', r' | | |', r' v | v', r' _\ | /_', r'|____|____|'], + ], + }; +} + +List> _largeHitFrames(MonsterCategory category) { + return [ + [r' *!*!*', r' (>___<)', r' \\X//', r' / \\// \', r' | \\/ |', r' | / \ |', r' _|/ \|_', r'|___/\\___|'], + [r' !*!*!', r' (@___@)', r' //X\\', r' \ /\\/ /', r' | //\\ |', r' | \ / |', r' _|\ /|_', r'|___\\/__|'], + ]; +} + +List> _largeAlertFrames(MonsterCategory category) { + return switch (category) { + MonsterCategory.beast => [ + [r' /\__/\', r' ( O!!O )', r' > !! <', r' /| |\', r' | | | |', r' | | | |', r'_| | | |_', r'|__|____|__|'], + [r' /\__/\', r' ( !!O! )', r' > !! <', r' \| |/', r' | | | |', r' | | | |', r'_| | | |_', r'|__|____|__|'], + ], + MonsterCategory.insect => [ + [r' /\/\/\', r' /! !\', r' \ /', r' / \', r' \/ \/', r' | \ / |', r' _| \/ |_', r'|___________|'], + [r' \/\/\/\', r' \! !/', r' / \', r' \ /', r' /\ /\', r' | / \ |', r' _| /\ |_', r'|___________|'], + ], + MonsterCategory.humanoid => [ + [r' O!', r' /|\', r' / \', r' | |', r' | |', r' | |', r' _| |_', r'|_________|'], + [r' !O', r' \|/', r' | |', r' | |', r' | |', r' | |', r' _/ \_', r'|_________|'], + ], + MonsterCategory.undead => [ + [r' _/!\_', r' (!___!)', r' /|||\', r' / ||| \', r' | ||| |', r' | / \ |', r' _|/ \|_', r'|_/ \_|'], + [r' _\!/_', r' (!___!)', r' \|||/', r' \ ||| /', r' | ||| |', r' | \ / |', r' _|\ /|_', r'|_\ /_|'], + ], + MonsterCategory.dragon => [ + [r' ___', r' <<(! !)~~', r' / || \', r' / || \', r' | || |', r' | || |', r' _| || |_', r'<___________|>'], + [r' ___', r' (! !)>>', r' \ || /', r' \ || /', r' | || |', r' | || |', r' _| || |_', r'<___________|>'], + ], + MonsterCategory.slime => [ + [r' _____', r' / ! \', r' / ! \', r' ( ! )', r' ( ! )', r' \ /', r' \_____/', r' \___/'], + [r' ___', r' / ! \', r' / ! \', r' { ! }', r' { ! }', r' \ /', r' \___/', r' \_/'], + ], + MonsterCategory.demon => [ + [r' ^!^', r' /|!|\', r' /|\', r' / | \', r' | | |', r' V | V', r' _/ | \_', r'|____|____|'], + [r' ^!^', r' \|!|/', r' \|/', r' \ | /', r' | | |', r' v | v', r' _\ | /_', r'|____|____|'], + ], + }; +} + +// ============================================================================ +// Huge+ 몬스터 (8줄, 더 넓게) +// ============================================================================ + +List> _hugeIdleFrames(MonsterCategory category) { + return switch (category) { + MonsterCategory.beast => [ + [r' /\____/\', r' ( O O )', r' > ^^^^ <', r' /| |\', r' | | | |', r' | | | |', r' _| | | |_', r'|___|______|___|'], + [r' /\____/\', r' ( O O )', r' > vvvv <', r' \| |/', r' | | | |', r' | | | |', r' _| | | |_', r'|___|______|___|'], + ], + MonsterCategory.insect => [ + [r' /\/\/\/\', r' /O O\', r' \ /', r' / \', r' \/ \/', r' | \ / |', r' _| \ / |_', r'|_______________|'], + [r' \/\/\/\/\', r' \O O/', r' / \', r' \ /', r' /\ /\', r' | / \ |', r' _| / \ |_', r'|_______________|'], + ], + MonsterCategory.humanoid => [ + [r' O', r' _/|\\_', r' / | \\', r' | |', r' | |', r' | |', r' _| |_', r'|___________|'], + [r' O', r' \\_|_/', r' \\|/', r' | |', r' | |', r' | |', r' _/ \\_', r'|___________|'], + ], + MonsterCategory.undead => [ + [r' _/+\\_', r' (X_____X)', r' /|||||\', r' / ||||| \\', r' | ||||| |', r' | / \\ |', r' _|/ \\|_', r'|_/ \\_|'], + [r' _\\+/_', r' (x_____x)', r' \\|||||/', r' \\ ||||| /', r' | ||||| |', r' | \\ / |', r' _|\\ /|_', r'|_\\ /_|'], + ], + MonsterCategory.dragon => [ + [r' ____', r' <<<(O O)~~~', r' / |||| \\', r' / |||| \\', r' | |||| |', r' | |||| |', r' _| |||| |_', r'<_______________|>'], + [r' ____', r' (O O)>>>', r' \\ |||| /', r' \\ |||| /', r' | |||| |', r' | |||| |', r' _| |||| |_', r'<_______________|>'], + ], + MonsterCategory.slime => [ + [r' ______', r' / \\', r' / \\', r' ( )', r' ( )', r' \\ /', r' \\______/', r' \\____/'], + [r' ____', r' / \\', r' / \\', r' { }', r' { }', r' \\ /', r' \\____/', r' \\__/'], + ], + MonsterCategory.demon => [ + [r' ^W^', r' /|O|\\ ', r' /|\\', r' / | \\', r' | | |', r' V | V', r' _/ | \\_', r'|_____|_____|'], + [r' ^w^', r' \\|o|/', r' \\|/', r' \\ | /', r' | | |', r' v | v', r' _\\ | /_', r'|_____|_____|'], + ], + }; +} + +List> _hugeHitFrames(MonsterCategory category) { + return [ + [r' *!*!*!*', r' (>_____<)', r' \\\\X////', r' / \\\\// \\', r' | \\\\/ |', r' | / \\ |', r' _|/ \\|_', r'|____/\\\\___|'], + [r' !*!*!*!', r' (@_____@)', r' ////X\\\\', r' \\ /\\\\/ /', r' | ////\\\\ |', r' | \\ / |', r' _|\\ /|_', r'|____\\\\/___|'], + ]; +} + +List> _hugeAlertFrames(MonsterCategory category) { + return switch (category) { + MonsterCategory.beast => [ + [r' /\____/\', r' ( ! ! )', r' > !!!! <', r' /| |\', r' | | | |', r' | | | |', r' _| | | |_', r'|___|______|___|'], + [r' /\____/\', r' ( ! ! )', r' > !!!! <', r' \| |/', r' | | | |', r' | | | |', r' _| | | |_', r'|___|______|___|'], + ], + MonsterCategory.insect => [ + [r' /\/\/\/\', r' /! !\', r' \ /', r' / \', r' \/ \/', r' | \ / |', r' _| \ / |_', r'|_______________|'], + [r' \/\/\/\/\', r' \! !/', r' / \', r' \ /', r' /\ /\', r' | / \ |', r' _| / \ |_', r'|_______________|'], + ], + MonsterCategory.humanoid => [ + [r' O!', r' _/|\\__', r' / | \\', r' | |', r' | |', r' | |', r' _| |_', r'|___________|'], + [r' !O', r' \\_|_/', r' \\|/', r' | |', r' | |', r' | |', r' _/ \\_', r'|___________|'], + ], + MonsterCategory.undead => [ + [r' _/!\\__', r' (!_____!)', r' /|||||\', r' / ||||| \\', r' | ||||| |', r' | / \\ |', r' _|/ \\|_', r'|_/ \\_|'], + [r' _\\!/_', r' (!_____!)', r' \\|||||/', r' \\ ||||| /', r' | ||||| |', r' | \\ / |', r' _|\\ /|_', r'|_\\ /_|'], + ], + MonsterCategory.dragon => [ + [r' ____', r' <<<(! !)~~~', r' / |||| \\', r' / |||| \\', r' | |||| |', r' | |||| |', r' _| |||| |_', r'<_______________|>'], + [r' ____', r' (! !)>>>', r' \\ |||| /', r' \\ |||| /', r' | |||| |', r' | |||| |', r' _| |||| |_', r'<_______________|>'], + ], + MonsterCategory.slime => [ + [r' ______', r' / ! \\', r' / ! \\', r' ( ! )', r' ( ! )', r' \\ /', r' \\______/', r' \\____/'], + [r' ____', r' / ! \\', r' / ! \\', r' { ! }', r' { ! }', r' \\ /', r' \\____/', r' \\__/'], + ], + MonsterCategory.demon => [ + [r' ^!^', r' /|!|\\ ', r' /|\\', r' / | \\', r' | | |', r' V | V', r' _/ | \\_', r'|_____|_____|'], + [r' ^!^', r' \\|!|/', r' \\|/', r' \\ | /', r' | | |', r' v | v', r' _\\ | /_', r'|_____|_____|'], + ], + }; +} + +// 레거시 호환용 함수 +List> getMonsterFrames(MonsterCategory category, MonsterSize size) { + return _getMonsterIdleFrames(category, size); +} diff --git a/lib/src/core/animation/character_frames.dart b/lib/src/core/animation/character_frames.dart new file mode 100644 index 0000000..9d84613 --- /dev/null +++ b/lib/src/core/animation/character_frames.dart @@ -0,0 +1,178 @@ +// 캐릭터 애니메이션 프레임 (8줄 Stone Story RPG 스타일) +// 참조: Stone Story RPG - 상세하고 생동감 있는 ASCII 아트 + +/// 전투 페이즈 +enum BattlePhase { + /// 대치 상태 (기본) + idle, + + /// 공격 준비 + prepare, + + /// 공격 중 + attack, + + /// 피격 (몬스터가 맞음) + hit, + + /// 복귀 + recover, +} + +/// 캐릭터 프레임 데이터 +class CharacterFrame { + const CharacterFrame(this.lines); + + /// 프레임 데이터 (3줄) + final List lines; + + /// 방패 오버레이 적용 + /// 3줄 캐릭터: [0]=머리, [1]=몸통/팔, [2]=다리 + CharacterFrame withShield() { + if (lines.length < 2) return this; + final newLines = List.from(lines); + // 몸통 줄(1번줄, 팔 위치)에 방패 추가 + final bodyIdx = 1; + if (newLines[bodyIdx].length >= 2) { + // 첫 두 문자를 방패로 대체 + newLines[bodyIdx] = '[]${newLines[bodyIdx].substring(2)}'; + } else { + newLines[bodyIdx] = '[]${newLines[bodyIdx]}'; + } + return CharacterFrame(newLines); + } +} + +/// 특정 페이즈와 서브프레임에 해당하는 캐릭터 프레임 반환 +CharacterFrame getCharacterFrame(BattlePhase phase, int subFrame) { + final frames = switch (phase) { + BattlePhase.idle => _idleFrames, + BattlePhase.prepare => _prepareFrames, + BattlePhase.attack => _attackFrames, + BattlePhase.hit => _hitFrames, + BattlePhase.recover => _recoverFrames, + }; + + final index = subFrame % frames.length; + return frames[index]; +} + +// ============================================================================ +// 대기 프레임 (숨쉬기 애니메이션) - 4프레임, 심플 3줄 스타일, 폭 6자 +// ============================================================================ +const _idleFrames = [ + CharacterFrame([ + r' o ', + r' /|\ ', + r' / \ ', + ]), + CharacterFrame([ + r' o ', + r' /|\ ', + r' | | ', + ]), + CharacterFrame([ + r' o ', + r' /|\ ', + r' / \ ', + ]), + CharacterFrame([ + r' O ', + r' /|\ ', + r' / \ ', + ]), +]; + +// ============================================================================ +// 준비 프레임 (무기 들기) - 3프레임, 심플 3줄 스타일, 폭 6자 +// ============================================================================ +const _prepareFrames = [ + CharacterFrame([ + r' \o ', + r' |\ ', + r' / \ ', + ]), + CharacterFrame([ + r' _ ', + r' \o ', + r' / \ ', + ]), + CharacterFrame([ + r' \_ ', + r' \o/ ', + r' / \ ', + ]), +]; + +// ============================================================================ +// 공격 프레임 (전진 + 휘두르기) - 5프레임, 심플 3줄 스타일 +// ============================================================================ +const _attackFrames = [ + CharacterFrame([ + r' \_/ ', + r' o ', + r' /| ', + ]), + CharacterFrame([ + r' _/ ', + r' o ', + r' /|\ ', + ]), + CharacterFrame([ + r' o-- ', + r' /| ', + r' / \ ', + ]), + CharacterFrame([ + r' o ', + r' /|-- ', + r' / \ ', + ]), + CharacterFrame([ + r' o ', + r' /|\_ ', + r' / \ ', + ]), +]; + +// ============================================================================ +// 히트 프레임 (공격 명중) - 3프레임, 심플 3줄 스타일 +// ============================================================================ +const _hitFrames = [ + CharacterFrame([ + r' o ', + r' /|-* ', + r' / \ ', + ]), + CharacterFrame([ + r' o ', + r' /|=* ', + r' / \ ', + ]), + CharacterFrame([ + r' o ', + r' /|~* ', + r' / \ ', + ]), +]; + +// ============================================================================ +// 복귀 프레임 - 3프레임, 심플 3줄 스타일 +// ============================================================================ +const _recoverFrames = [ + CharacterFrame([ + r' o ', + r' /|\ ', + r' | ', + ]), + CharacterFrame([ + r' o ', + r' /|\ ', + r' / \ ', + ]), + CharacterFrame([ + r' o ', + r' /|\ ', + r' / \ ', + ]), +]; diff --git a/lib/src/core/animation/monster_colors.dart b/lib/src/core/animation/monster_colors.dart new file mode 100644 index 0000000..046b659 --- /dev/null +++ b/lib/src/core/animation/monster_colors.dart @@ -0,0 +1,192 @@ +// 몬스터 카테고리별 색상 시스템 +// 각 몬스터 카테고리에 따라 다른 색상 적용 + +import 'dart:ui'; + +/// 몬스터 카테고리 (ascii_animation_data.dart의 MonsterCategory와 매칭) +enum MonsterColorCategory { + beast, + insect, + humanoid, + undead, + dragon, + slime, + demon, +} + +/// 몬스터 색상 정보 +class MonsterColors { + const MonsterColors({ + required this.normal, + required this.hit, + }); + + /// 일반 상태 색상 + final Color normal; + + /// 피격 상태 색상 + final Color hit; +} + +/// 카테고리별 몬스터 색상 반환 +MonsterColors getMonsterColors(MonsterColorCategory category) { + return switch (category) { + MonsterColorCategory.beast => const MonsterColors( + normal: Color(0xFF00FF00), // 녹색 + hit: Color(0xFFFF0000), // 빨강 + ), + MonsterColorCategory.insect => const MonsterColors( + normal: Color(0xFFFFFF00), // 노랑 + hit: Color(0xFFFF6600), // 주황 + ), + MonsterColorCategory.humanoid => const MonsterColors( + normal: Color(0xFF00FFFF), // 시안 + hit: Color(0xFFFF00FF), // 마젠타 + ), + MonsterColorCategory.undead => const MonsterColors( + normal: Color(0xFF9966FF), // 보라 + hit: Color(0xFFCCCCCC), // 회색 + ), + MonsterColorCategory.dragon => const MonsterColors( + normal: Color(0xFFFF6600), // 주황 + hit: Color(0xFFFFFF00), // 노랑 + ), + MonsterColorCategory.slime => const MonsterColors( + normal: Color(0xFF66FF66), // 연녹색 + hit: Color(0xFF00CC00), // 진녹색 + ), + MonsterColorCategory.demon => const MonsterColors( + normal: Color(0xFFFF0066), // 핑크 + hit: Color(0xFFFFFFFF), // 흰색 + ), + }; +} + +/// 몬스터 기본 이름에서 색상 카테고리 추론 +/// +/// ascii_animation_data.dart의 getMonsterCategory 결과를 변환 +MonsterColorCategory getMonsterColorCategory(String? baseName) { + if (baseName == null || baseName.isEmpty) { + return MonsterColorCategory.beast; + } + + final lower = baseName.toLowerCase(); + + // insect (곤충류) + if (_matchesAny(lower, _insectKeywords)) { + return MonsterColorCategory.insect; + } + + // undead (언데드) + if (_matchesAny(lower, _undeadKeywords)) { + return MonsterColorCategory.undead; + } + + // dragon (드래곤류) + if (_matchesAny(lower, _dragonKeywords)) { + return MonsterColorCategory.dragon; + } + + // slime (슬라임류) + if (_matchesAny(lower, _slimeKeywords)) { + return MonsterColorCategory.slime; + } + + // demon (악마류) + if (_matchesAny(lower, _demonKeywords)) { + return MonsterColorCategory.demon; + } + + // humanoid (인간형) + if (_matchesAny(lower, _humanoidKeywords)) { + return MonsterColorCategory.humanoid; + } + + // 기본은 beast + return MonsterColorCategory.beast; +} + +bool _matchesAny(String text, List keywords) { + return keywords.any((kw) => text.contains(kw)); +} + +const _insectKeywords = [ + 'bug', + 'beetle', + 'spider', + 'ant', + 'bee', + 'wasp', + 'moth', + 'worm', + 'larva', + 'crawler', + 'centipede', + 'scorpion', +]; + +const _undeadKeywords = [ + 'zombie', + 'skeleton', + 'ghost', + 'wraith', + 'vampire', + 'lich', + 'specter', + 'phantom', + 'revenant', + 'undead', + 'corpse', + 'bone', +]; + +const _dragonKeywords = [ + 'dragon', + 'drake', + 'wyrm', + 'wyvern', + 'serpent', + 'hydra', + 'basilisk', +]; + +const _slimeKeywords = [ + 'slime', + 'ooze', + 'blob', + 'jelly', + 'pudding', + 'gel', + 'goo', +]; + +const _demonKeywords = [ + 'demon', + 'devil', + 'imp', + 'fiend', + 'daemon', + 'succubus', + 'incubus', + 'hell', + 'infernal', +]; + +const _humanoidKeywords = [ + 'goblin', + 'orc', + 'troll', + 'ogre', + 'giant', + 'bandit', + 'knight', + 'mage', + 'wizard', + 'warrior', + 'guard', + 'soldier', + 'cultist', + 'hacker', + 'admin', + 'user', +]; diff --git a/lib/src/core/animation/monster_size.dart b/lib/src/core/animation/monster_size.dart new file mode 100644 index 0000000..be13a69 --- /dev/null +++ b/lib/src/core/animation/monster_size.dart @@ -0,0 +1,49 @@ +// 몬스터 크기 시스템 +// 몬스터 레벨에 따라 ASCII 아트 크기 결정 + +/// 몬스터 크기 enum +enum MonsterSize { + /// 1줄 (레벨 1-5) + tiny(1), + + /// 2줄 (레벨 6-10) + small(2), + + /// 3줄 (레벨 11-15) + medium(3), + + /// 4줄 (레벨 16-25) + large(4), + + /// 5줄 (레벨 26-35) + huge(5), + + /// 6줄 (레벨 36-50) + giant(6), + + /// 7줄 (레벨 51+, 보스급) + titanic(7); + + const MonsterSize(this.lines); + + /// 해당 크기의 줄 수 + final int lines; +} + +/// 몬스터 레벨에서 크기 결정 +MonsterSize getMonsterSize(int? level) { + if (level == null || level <= 0) return MonsterSize.tiny; + + if (level <= 5) return MonsterSize.tiny; + if (level <= 10) return MonsterSize.small; + if (level <= 15) return MonsterSize.medium; + if (level <= 25) return MonsterSize.large; + if (level <= 35) return MonsterSize.huge; + if (level <= 50) return MonsterSize.giant; + return MonsterSize.titanic; +} + +/// 몬스터 크기에 따른 세로 패딩 계산 (7줄 프레임에서 중앙 정렬) +int getMonsterVerticalPadding(MonsterSize size) { + return (7 - size.lines) ~/ 2; +} diff --git a/lib/src/core/animation/weapon_category.dart b/lib/src/core/animation/weapon_category.dart new file mode 100644 index 0000000..ccf886e --- /dev/null +++ b/lib/src/core/animation/weapon_category.dart @@ -0,0 +1,109 @@ +/// 무기 카테고리 (공격 스타일 결정용) +enum WeaponCategory { + /// 둔기류 - 휘두르기/타격 + /// Keyboard, Mouse, Monitor Stand, Server Rack 등 + blunt, + + /// 케이블류 - 채찍질 + /// USB Cable, Ethernet Cord, Fiber Optic 등 + cable, + + /// 칩류 - 투척/발사 + /// SSD, RAM Stick, GPU 등 + projectile, + + /// 프로세서류 - 에너지 빔 + /// Tensor Core, TPU, Neural Processor 등 + energy, + + /// 우주급 - 초월적 공격 + /// Dyson Sphere, Black Hole Computer, Universe Simulator + cosmic, + + /// 기본 (무기 없음) + unarmed, +} + +/// 무기 이름에서 카테고리를 결정 +/// +/// 무기 이름의 키워드를 분석하여 공격 스타일 결정. +/// 예: "Flaming USB Cable" → cable +WeaponCategory getWeaponCategory(String? weaponName) { + if (weaponName == null || weaponName.isEmpty) { + return WeaponCategory.unarmed; + } + + final lower = weaponName.toLowerCase(); + + // 우주급 (가장 먼저 체크 - 가장 특별함) + if (_matchesAny(lower, _cosmicKeywords)) { + return WeaponCategory.cosmic; + } + + // 케이블류 + if (_matchesAny(lower, _cableKeywords)) { + return WeaponCategory.cable; + } + + // 에너지/프로세서류 + if (_matchesAny(lower, _energyKeywords)) { + return WeaponCategory.energy; + } + + // 칩/메모리류 + if (_matchesAny(lower, _projectileKeywords)) { + return WeaponCategory.projectile; + } + + // 나머지는 모두 둔기류 + return WeaponCategory.blunt; +} + +bool _matchesAny(String text, List keywords) { + return keywords.any((kw) => text.contains(kw)); +} + +// 카테고리별 키워드 목록 + +const _cosmicKeywords = [ + 'dyson', + 'black hole', + 'universe', + 'singularity', +]; + +const _cableKeywords = [ + 'cable', + 'cord', + 'fiber', + 'optic', + 'submarine', + 'satellite', + 'link', + 'ethernet', + 'usb', +]; + +const _energyKeywords = [ + 'tensor', + 'tpu', + 'fpga', + 'asic', + 'quantum', + 'photonic', + 'neural', + 'entangler', + 'processor', + 'core', +]; + +const _projectileKeywords = [ + 'ssd', + 'nvme', + 'raid', + 'ram', + 'gpu', + 'drive', + 'stick', + 'array', +]; diff --git a/lib/src/core/animation/weapon_effects.dart b/lib/src/core/animation/weapon_effects.dart new file mode 100644 index 0000000..8556e52 --- /dev/null +++ b/lib/src/core/animation/weapon_effects.dart @@ -0,0 +1,184 @@ +import 'package:askiineverdie/src/core/animation/weapon_category.dart'; + +/// 무기 카테고리별 공격 이펙트 ASCII 프레임 +/// +/// 각 이펙트는 멀티라인 (최대 5줄, 24자 폭). +/// 캐릭터와 몬스터 사이에 표시됨. + +class WeaponEffect { + const WeaponEffect({ + required this.prepareFrames, + required this.attackFrames, + required this.hitFrames, + this.hitSound = '*HIT!*', + this.effectHeight = 3, + this.effectYStart = 2, + }); + + /// 준비 프레임 (멀티라인) + final List> prepareFrames; + + /// 공격 프레임 (멀티라인) + final List> attackFrames; + + /// 히트 프레임 (멀티라인) + final List> hitFrames; + + /// 히트 효과음 텍스트 + final String hitSound; + + /// 이펙트 높이 (줄 수) + final int effectHeight; + + /// 이펙트 시작 Y 위치 (0~7) + final int effectYStart; +} + +/// 카테고리별 무기 이펙트 반환 +WeaponEffect getWeaponEffect(WeaponCategory category) { + return switch (category) { + WeaponCategory.blunt => _bluntEffect, + WeaponCategory.cable => _cableEffect, + WeaponCategory.projectile => _projectileEffect, + WeaponCategory.energy => _energyEffect, + WeaponCategory.cosmic => _cosmicEffect, + WeaponCategory.unarmed => _unarmedEffect, + }; +} + +// ============================================================================ +// 둔기류 - 휘두르기 (3줄) +// ============================================================================ +const _bluntEffect = WeaponEffect( + prepareFrames: [ + [r' _', r' /', r' /'], + [r' _/', r' / ', r' / '], + ], + attackFrames: [ + [r' _/ ', r' / ', r'/ '], + [r' /__ ', r'/ ', r' '], + [r'/__ ', r' ', r' '], + [r'/__=>', r' ', r' '], + ], + hitFrames: [ + [r' *BASH* ', r'/__=> ', r' '], + [r'*SMASH!*', r' /__ ', r' '], + ], + hitSound: '*BASH*', + effectHeight: 3, + effectYStart: 2, +); + +// ============================================================================ +// 케이블류 - 채찍질 (3줄) +// ============================================================================ +const _cableEffect = WeaponEffect( + prepareFrames: [ + [r' ', r'~ ', r' ~ '], + [r' ', r'~~ ', r' ~ '], + ], + attackFrames: [ + [r' ', r'~~~ ', r' ~~ '], + [r' ', r'~~~~ ', r' ~~ '], + [r' ', r'~~~~~> ', r' ~~ '], + [r' ', r'~~~~~~> ', r' ~~'], + ], + hitFrames: [ + [r' *WHIP*', r'~~~~~~> ', r' ~~'], + [r' *CRACK*', r'~~~~~> ', r' ~~ '], + ], + hitSound: '*WHIP*', + effectHeight: 3, + effectYStart: 2, +); + +// ============================================================================ +// 투척류 - 발사 (3줄) +// ============================================================================ +const _projectileEffect = WeaponEffect( + prepareFrames: [ + [r' ', r'[=] ', r' '], + [r' ', r'[==] ', r' '], + ], + attackFrames: [ + [r' ', r' [> ', r' '], + [r' ', r' [>', r' '], + [r' ', r' [>', r' '], + [r' ', r' [>', r' '], + ], + hitFrames: [ + [r' *CLANG*', r' [>', r' '], + [r' *CRASH* ', r' [> ', r' '], + ], + hitSound: '*CLANG*', + effectHeight: 3, + effectYStart: 2, +); + +// ============================================================================ +// 에너지류 - 빔 발사 (3줄) +// ============================================================================ +const _energyEffect = WeaponEffect( + prepareFrames: [ + [r' ', r' <*> ', r' '], + [r' == ', r' <**> ', r' == '], + ], + attackFrames: [ + [r' ==== ', r'==<*>== ', r' ==== '], + [r' ====== ', r'===<*>==', r' ====== '], + [r'========', r'===<*>==', r'========'], + [r'========', r'====<*>=', r'========'], + ], + hitFrames: [ + [r'==*ZAP*=', r'===<*>==', r'========'], + [r'*BZZT!*=', r'====<*>=', r'========'], + ], + hitSound: '*ZAP*', + effectHeight: 3, + effectYStart: 2, +); + +// ============================================================================ +// 우주급 - 초월적 공격 (5줄) +// ============================================================================ +const _cosmicEffect = WeaponEffect( + prepareFrames: [ + [r' * ', r' @ @ ', r' @ @ ', r' @ @ ', r' * '], + [r' * * ', r' @ @ ', r' @ @', r' @ @ ', r' * * '], + ], + attackFrames: [ + [r' * ', r' * * * ', r' * * * *', r' * * * ', r' * '], + [r' *** ', r' * * * *', r'* * * * ', r' * * * *', r' *** '], + [r' ***** ', r' ******* ', r'*********', r' ******* ', r' ***** '], + [r' **VOID**', r'*********', r'*********', r'*********', r' **VOID**'], + ], + hitFrames: [ + [r'*SINGULAR', r'*********', r'***!!!***', r'*********', r'*DESTROY*'], + [r'!!!VOID!!', r'*********', r'*********', r'*********', r'!!!VOID!!'], + ], + hitSound: '***VOID***', + effectHeight: 5, + effectYStart: 1, +); + +// ============================================================================ +// 맨손 - 기본 펀치 (3줄) +// ============================================================================ +const _unarmedEffect = WeaponEffect( + prepareFrames: [ + [r' ', r' ', r' '], + [r' ', r' > ', r' '], + ], + attackFrames: [ + [r' ', r'-> ', r' '], + [r' ', r'---> ', r' '], + [r' ', r'-----> ', r' '], + ], + hitFrames: [ + [r' *POW* ', r'-----> ', r' '], + [r'*PUNCH*', r'----> ', r' '], + ], + hitSound: '*POW*', + effectHeight: 3, + effectYStart: 2, +); diff --git a/lib/src/core/engine/progress_service.dart b/lib/src/core/engine/progress_service.dart index 71720b9..4308663 100644 --- a/lib/src/core/engine/progress_service.dart +++ b/lib/src/core/engine/progress_service.dart @@ -383,6 +383,7 @@ class ProgressService { type: TaskType.kill, monsterBaseName: monsterResult.baseName, monsterPart: monsterResult.part, + monsterLevel: monsterResult.level, ), ); diff --git a/lib/src/core/model/game_state.dart b/lib/src/core/model/game_state.dart index 11bac29..6b04d0d 100644 --- a/lib/src/core/model/game_state.dart +++ b/lib/src/core/model/game_state.dart @@ -95,6 +95,7 @@ class TaskInfo { required this.type, this.monsterBaseName, this.monsterPart, + this.monsterLevel, }); final String caption; @@ -106,6 +107,9 @@ class TaskInfo { /// 킬 태스크의 전리품 부위 (예: "claw", "tail", "*"는 WinItem) final String? monsterPart; + /// 킬 태스크의 몬스터 레벨 (애니메이션 크기 결정용) + final int? monsterLevel; + factory TaskInfo.empty() => const TaskInfo(caption: '', type: TaskType.neutral); @@ -114,12 +118,14 @@ class TaskInfo { TaskType? type, String? monsterBaseName, String? monsterPart, + int? monsterLevel, }) { return TaskInfo( caption: caption ?? this.caption, type: type ?? this.type, monsterBaseName: monsterBaseName ?? this.monsterBaseName, monsterPart: monsterPart ?? this.monsterPart, + monsterLevel: monsterLevel ?? this.monsterLevel, ); } } diff --git a/lib/src/features/game/game_play_screen.dart b/lib/src/features/game/game_play_screen.dart index 7dc61ed..9dd2980 100644 --- a/lib/src/features/game/game_play_screen.dart +++ b/lib/src/features/game/game_play_screen.dart @@ -227,6 +227,10 @@ class _GamePlayScreenState extends State colorTheme: _colorTheme, onThemeCycle: _cycleColorTheme, specialAnimation: _specialAnimation, + weaponName: state.equipment.weapon, + shieldName: state.equipment.shield, + characterLevel: state.traits.level, + monsterLevel: state.progress.currentTask.monsterLevel, ), // 메인 3패널 영역 diff --git a/lib/src/features/game/widgets/ascii_animation_card.dart b/lib/src/features/game/widgets/ascii_animation_card.dart index 32167d2..0bea3ea 100644 --- a/lib/src/features/game/widgets/ascii_animation_card.dart +++ b/lib/src/features/game/widgets/ascii_animation_card.dart @@ -4,6 +4,12 @@ import 'package:flutter/material.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/animation/background_layer.dart'; +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_colors.dart'; +import 'package:askiineverdie/src/core/animation/monster_size.dart'; +import 'package:askiineverdie/src/core/animation/weapon_category.dart'; import 'package:askiineverdie/src/core/model/game_state.dart'; /// ASCII 애니메이션 카드 위젯 @@ -19,6 +25,10 @@ class AsciiAnimationCard extends StatefulWidget { this.monsterBaseName, this.colorTheme = AsciiColorTheme.green, this.specialAnimation, + this.weaponName, + this.shieldName, + this.characterLevel, + this.monsterLevel, }); final TaskType taskType; @@ -31,6 +41,18 @@ class AsciiAnimationCard extends StatefulWidget { /// 설정되면 일반 애니메이션 대신 표시 final AsciiAnimationType? specialAnimation; + /// 현재 장착 무기 이름 (공격 스타일 결정용) + final String? weaponName; + + /// 현재 장착 방패 이름 (방패 표시용) + final String? shieldName; + + /// 캐릭터 레벨 + final int? characterLevel; + + /// 몬스터 레벨 (몬스터 크기 결정용) + final int? monsterLevel; + @override State createState() => _AsciiAnimationCardState(); } @@ -41,6 +63,29 @@ class _AsciiAnimationCardState extends State { late AsciiAnimationData _animationData; AsciiAnimationType? _currentSpecialAnimation; + // 전투 애니메이션 상태 + bool _isBattleMode = false; + BattlePhase _battlePhase = BattlePhase.idle; + int _battleSubFrame = 0; + BattleComposer? _battleComposer; + + // 글로벌 틱 (배경 스크롤용) + int _globalTick = 0; + + // 환경 타입 + EnvironmentType _environment = EnvironmentType.forest; + + // 전투 페이즈 시퀀스 (반복) + static const _battlePhaseSequence = [ + (BattlePhase.idle, 4), // 4 프레임 대기 + (BattlePhase.prepare, 2), // 2 프레임 준비 + (BattlePhase.attack, 3), // 3 프레임 공격 + (BattlePhase.hit, 2), // 2 프레임 히트 + (BattlePhase.recover, 2), // 2 프레임 복귀 + ]; + int _phaseIndex = 0; + int _phaseFrameCount = 0; + @override void initState() { super.initState(); @@ -64,7 +109,10 @@ class _AsciiAnimationCardState extends State { } if (oldWidget.taskType != widget.taskType || - oldWidget.monsterBaseName != widget.monsterBaseName) { + oldWidget.monsterBaseName != widget.monsterBaseName || + oldWidget.weaponName != widget.weaponName || + oldWidget.shieldName != widget.shieldName || + oldWidget.monsterLevel != widget.monsterLevel) { _updateAnimation(); } } @@ -74,6 +122,7 @@ class _AsciiAnimationCardState extends State { // 특수 애니메이션이 있으면 우선 적용 if (_currentSpecialAnimation != null) { + _isBattleMode = false; _animationData = getAnimationData(_currentSpecialAnimation!); _currentFrame = 0; @@ -99,26 +148,80 @@ class _AsciiAnimationCardState extends State { // 일반 애니메이션 처리 final animationType = taskTypeToAnimation(widget.taskType); - // 전투 타입이면 몬스터 카테고리에 따라 다른 애니메이션 선택 + // 전투 타입이면 새 BattleComposer 시스템 사용 if (animationType == AsciiAnimationType.battle) { - final category = getMonsterCategory(widget.monsterBaseName); - _animationData = getBattleAnimation(category); + _isBattleMode = true; + _setupBattleComposer(); + _battlePhase = BattlePhase.idle; + _battleSubFrame = 0; + _phaseIndex = 0; + _phaseFrameCount = 0; + + _timer = Timer.periodic( + const Duration(milliseconds: 200), + (_) => _advanceBattleFrame(), + ); } else { + _isBattleMode = false; _animationData = getAnimationData(animationType); + _currentFrame = 0; + + _timer = Timer.periodic( + Duration(milliseconds: _animationData.frameIntervalMs), + (_) { + if (mounted) { + setState(() { + _currentFrame = + (_currentFrame + 1) % _animationData.frames.length; + }); + } + }, + ); } + } - _currentFrame = 0; + void _setupBattleComposer() { + final weaponCategory = getWeaponCategory(widget.weaponName); + final hasShield = + widget.shieldName != null && widget.shieldName!.isNotEmpty; + final monsterCategory = getMonsterCategory(widget.monsterBaseName); + final monsterSize = getMonsterSize(widget.monsterLevel); - _timer = Timer.periodic( - Duration(milliseconds: _animationData.frameIntervalMs), - (_) { - if (mounted) { - setState(() { - _currentFrame = (_currentFrame + 1) % _animationData.frames.length; - }); - } - }, + _battleComposer = BattleComposer( + weaponCategory: weaponCategory, + hasShield: hasShield, + monsterCategory: monsterCategory, + monsterSize: monsterSize, ); + + // 환경 타입 추론 + _environment = inferEnvironment( + widget.taskType.name, + widget.monsterBaseName, + ); + } + + void _advanceBattleFrame() { + if (!mounted) return; + + setState(() { + // 글로벌 틱 증가 (배경 스크롤용) + _globalTick++; + + _phaseFrameCount++; + final currentPhase = _battlePhaseSequence[_phaseIndex]; + + // 현재 페이즈의 프레임 수를 초과하면 다음 페이즈로 + if (_phaseFrameCount >= currentPhase.$2) { + _phaseIndex = (_phaseIndex + 1) % _battlePhaseSequence.length; + _phaseFrameCount = 0; + _battleSubFrame = 0; + } else { + _battleSubFrame++; + } + + _battlePhase = _battlePhaseSequence[_phaseIndex].$1; + }); } @override @@ -138,11 +241,35 @@ class _AsciiAnimationCardState extends State { ? colors.backgroundColor.withValues(alpha: 0.95) : colors.backgroundColor; - // 프레임 인덱스가 범위를 벗어나지 않도록 보정 - final frameIndex = _currentFrame.clamp(0, _animationData.frames.length - 1); + // 프레임 텍스트 결정 + String frameText; + Color textColor = colors.textColor; + + if (_isBattleMode && _battleComposer != null) { + // 새 배틀 시스템 사용 (배경 포함) + frameText = _battleComposer!.composeFrameWithBackground( + _battlePhase, + _battleSubFrame, + widget.monsterBaseName, + _environment, + _globalTick, + ); + + // 히트 페이즈면 몬스터 색상 변경 + if (_battlePhase == BattlePhase.hit) { + final monsterColorCategory = + getMonsterColorCategory(widget.monsterBaseName); + textColor = getMonsterColors(monsterColorCategory).hit; + } + } else { + // 기존 레거시 시스템 사용 + final frameIndex = + _currentFrame.clamp(0, _animationData.frames.length - 1); + frameText = _animationData.frames[frameIndex]; + } return Container( - padding: const EdgeInsets.all(8), + padding: const EdgeInsets.all(4), decoration: BoxDecoration( color: bgColor, borderRadius: BorderRadius.circular(4), @@ -150,19 +277,49 @@ class _AsciiAnimationCardState extends State { ? Border.all(color: colors.textColor.withValues(alpha: 0.5)) : null, ), - child: Center( - child: Text( - _animationData.frames[frameIndex], - style: TextStyle( - fontFamily: 'monospace', - fontSize: 10, - color: colors.textColor, - height: 1.1, - letterSpacing: 0, - ), - textAlign: TextAlign.center, - ), - ), + child: _isBattleMode + ? LayoutBuilder( + builder: (context, constraints) { + // 60x8 프레임에 맞게 폰트 크기 자동 계산 + // ASCII 문자 비율: 너비 = 높이 * 0.6 (모노스페이스) + final maxWidth = constraints.maxWidth; + final maxHeight = constraints.maxHeight; + // 60자 폭, 8줄 높이 기준 + final fontSizeByWidth = maxWidth / 60 / 0.6; + final fontSizeByHeight = maxHeight / 8 / 1.2; + final fontSize = (fontSizeByWidth < fontSizeByHeight + ? fontSizeByWidth + : fontSizeByHeight) + .clamp(6.0, 14.0); + + return Center( + child: Text( + frameText, + style: TextStyle( + fontFamily: 'Courier', + fontSize: fontSize, + color: textColor, + height: 1.2, + letterSpacing: 0, + ), + textAlign: TextAlign.left, + ), + ); + }, + ) + : Center( + child: Text( + frameText, + style: TextStyle( + fontFamily: 'monospace', + fontSize: 10, + color: textColor, + height: 1.1, + letterSpacing: 0, + ), + textAlign: TextAlign.center, + ), + ), ); } } diff --git a/lib/src/features/game/widgets/task_progress_panel.dart b/lib/src/features/game/widgets/task_progress_panel.dart index c43c82c..76386ce 100644 --- a/lib/src/features/game/widgets/task_progress_panel.dart +++ b/lib/src/features/game/widgets/task_progress_panel.dart @@ -16,6 +16,10 @@ class TaskProgressPanel extends StatelessWidget { required this.colorTheme, required this.onThemeCycle, this.specialAnimation, + this.weaponName, + this.shieldName, + this.characterLevel, + this.monsterLevel, }); final ProgressState progress; @@ -27,6 +31,12 @@ class TaskProgressPanel extends StatelessWidget { /// 특수 애니메이션 (레벨업, 퀘스트 완료 등) final AsciiAnimationType? specialAnimation; + /// 장비 정보 (애니메이션 스타일 결정용) + final String? weaponName; + final String? shieldName; + final int? characterLevel; + final int? monsterLevel; + @override Widget build(BuildContext context) { return Container( @@ -48,6 +58,10 @@ class TaskProgressPanel extends StatelessWidget { monsterBaseName: progress.currentTask.monsterBaseName, colorTheme: colorTheme, specialAnimation: specialAnimation, + weaponName: weaponName, + shieldName: shieldName, + characterLevel: characterLevel, + monsterLevel: monsterLevel, ), ), const SizedBox(height: 8),