fix(animation): ASCII 애니메이션 높낮이/공백 문제 수정

- walkingAnimation, townAnimation 4줄 → 3줄 통일
- character_frames.dart 모든 프레임 폭 6자로 통일
- _compose() 이펙트 Y 위치 동적 계산 (하드코딩 제거)
- withShield() 3줄 캐릭터용으로 수정 (index 3 → index 1)
- BattleComposer 캔버스 시스템 및 배경 합성 추가
- 무기 카테고리별 이펙트, 몬스터 크기/색상 시스템 구현
This commit is contained in:
JiWoong Sul
2025-12-13 18:22:50 +09:00
parent e30177e788
commit 598c25e4c9
14 changed files with 2052 additions and 355 deletions

View File

@@ -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,
);

View File

@@ -0,0 +1,178 @@
// 환경별 배경 패턴 데이터
// ASCII Patrol 스타일 - 패럴렉스 스크롤링 배경
import 'package:askiineverdie/src/core/animation/background_layer.dart';
/// 환경별 배경 레이어 반환
List<BackgroundLayer> 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,
),
];

View File

@@ -0,0 +1,92 @@
// 배경 레이어 시스템 (ASCII Patrol 스타일 패럴렉스)
// 각 환경은 여러 레이어로 구성되며, 레이어마다 다른 스크롤 속도를 가짐
/// 배경 레이어 데이터
class BackgroundLayer {
const BackgroundLayer({
required this.lines,
required this.scrollSpeed,
this.yStart = 0,
});
/// 레이어 패턴 (각 줄은 반복 가능한 패턴)
final List<String> 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;
}

View File

@@ -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<String> _normalizeSprite(List<String> sprite, int width) {
return sprite.map((line) => line.padRight(width).substring(0, width)).toList();
}
/// 스프라이트를 지정 폭으로 정규화 (오른쪽 정렬)
List<String> _normalizeSpriteRight(List<String> sprite, int width) {
return sprite.map((line) {
final trimmed = line.trimRight();
if (trimmed.length >= width) return trimmed.substring(0, width);
return trimmed.padLeft(width);
}).toList();
}
/// 스프라이트를 캔버스에 오버레이 (공백도 덮어쓰기 - Z-order용)
void _overlaySpriteWithSpaces(
List<List<String>> canvas,
List<String> sprite,
int startX,
int startY,
) {
for (var i = 0; i < sprite.length; i++) {
final y = startY + i;
if (y < 0 || y >= frameHeight) continue;
final line = sprite[i];
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<List<String>> 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<List<String>> 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<String> _getEffectLines(
WeaponEffect effect, BattlePhase phase, int subFrame) {
final frames = switch (phase) {
BattlePhase.idle => <List<String>>[],
BattlePhase.prepare => effect.prepareFrames,
BattlePhase.attack => effect.attackFrames,
BattlePhase.hit => effect.hitFrames,
BattlePhase.recover => <List<String>>[],
};
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<String> charLines,
List<String> monsterLines,
String effectLine,
BattlePhase phase,
) {
final result = <String>[];
// 캐릭터와 몬스터를 하단 정렬 (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<List<String>> _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<List<String>> _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<List<String>> _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<List<String>> _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<List<String>> _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<List<String>> _tinyHitFrames(MonsterCategory category) {
return [
[r'*!', r'><'],
[r'!*', r'<>'],
];
}
List<List<String>> _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<List<String>> _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<List<String>> _smallHitFrames(MonsterCategory category) {
return [
[r' *!*', r' (>_<)', r' \X/', r' _/_\_'],
[r' !*!', r' (@_@)', r' /X\', r' _\_/_'],
];
}
List<List<String>> _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<List<String>> _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<List<String>> _mediumHitFrames(MonsterCategory category) {
return [
[r' *!*', r' (>.<)', r' \X/', r' / \', r' | |', r'_/_ \_\'],
[r' !*!', r' (@_@)', r' /X\', r' \ /', r' | |', r'_\_ /_/'],
];
}
List<List<String>> _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<List<String>> _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<List<String>> _largeHitFrames(MonsterCategory category) {
return [
[r' *!*!*', r' (>___<)', r' \\X//', r' / \\// \', r' | \\/ |', r' | / \ |', r' _|/ \|_', r'|___/\\___|'],
[r' !*!*!', r' (@___@)', r' //X\\', r' \ /\\/ /', r' | //\\ |', r' | \ / |', r' _|\ /|_', r'|___\\/__|'],
];
}
List<List<String>> _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<List<String>> _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<List<String>> _hugeHitFrames(MonsterCategory category) {
return [
[r' *!*!*!*', r' (>_____<)', r' \\\\X////', r' / \\\\// \\', r' | \\\\/ |', r' | / \\ |', r' _|/ \\|_', r'|____/\\\\___|'],
[r' !*!*!*!', r' (@_____@)', r' ////X\\\\', r' \\ /\\\\/ /', r' | ////\\\\ |', r' | \\ / |', r' _|\\ /|_', r'|____\\\\/___|'],
];
}
List<List<String>> _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<List<String>> getMonsterFrames(MonsterCategory category, MonsterSize size) {
return _getMonsterIdleFrames(category, size);
}

View File

@@ -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<String> lines;
/// 방패 오버레이 적용
/// 3줄 캐릭터: [0]=머리, [1]=몸통/팔, [2]=다리
CharacterFrame withShield() {
if (lines.length < 2) return this;
final newLines = List<String>.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' / \ ',
]),
];

View File

@@ -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<String> 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',
];

View File

@@ -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;
}

View File

@@ -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<String> 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',
];

View File

@@ -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<List<String>> prepareFrames;
/// 공격 프레임 (멀티라인)
final List<List<String>> attackFrames;
/// 히트 프레임 (멀티라인)
final List<List<String>> 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,
);

View File

@@ -383,6 +383,7 @@ class ProgressService {
type: TaskType.kill,
monsterBaseName: monsterResult.baseName,
monsterPart: monsterResult.part,
monsterLevel: monsterResult.level,
),
);

View File

@@ -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,
);
}
}

View File

@@ -227,6 +227,10 @@ class _GamePlayScreenState extends State<GamePlayScreen>
colorTheme: _colorTheme,
onThemeCycle: _cycleColorTheme,
specialAnimation: _specialAnimation,
weaponName: state.equipment.weapon,
shieldName: state.equipment.shield,
characterLevel: state.traits.level,
monsterLevel: state.progress.currentTask.monsterLevel,
),
// 메인 3패널 영역

View File

@@ -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<AsciiAnimationCard> createState() => _AsciiAnimationCardState();
}
@@ -41,6 +63,29 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
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<AsciiAnimationCard> {
}
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<AsciiAnimationCard> {
// 특수 애니메이션이 있으면 우선 적용
if (_currentSpecialAnimation != null) {
_isBattleMode = false;
_animationData = getAnimationData(_currentSpecialAnimation!);
_currentFrame = 0;
@@ -99,26 +148,80 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
// 일반 애니메이션 처리
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<AsciiAnimationCard> {
? 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<AsciiAnimationCard> {
? 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,
),
),
);
}
}

View File

@@ -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),