refactor(shared): animation, l10n, theme 모듈을 core에서 shared로 이동

- core/animation → shared/animation
- core/l10n → shared/l10n
- core/constants/ascii_colors → shared/theme/ascii_colors
- import 경로 업데이트
This commit is contained in:
JiWoong Sul
2026-02-23 15:49:14 +09:00
parent 8fcb7bf2b7
commit 8f351df0b6
24 changed files with 1409 additions and 1498 deletions

View File

@@ -1,760 +0,0 @@
import 'package:flutter/material.dart';
import 'package:asciineverdie/src/core/animation/ascii_animation_type.dart';
/// ASCII 애니메이션 프레임 데이터
class AsciiAnimationData {
const AsciiAnimationData({required this.frames, this.frameIntervalMs = 200});
/// 각 프레임 (문자열, 최소 5줄)
final List<String> frames;
/// 프레임 간격 (밀리초)
final int frameIntervalMs;
}
/// 터미널 색상 테마
enum AsciiColorTheme {
/// 클래식 녹색 터미널
green,
/// 엠버 (호박색) 터미널
amber,
/// 화이트 온 블랙
white,
/// 시스템 테마 (라이트/다크 모드 따름)
system,
}
/// 테마별 색상 데이터
class AsciiThemeColors {
const AsciiThemeColors({
required this.textColor,
required this.backgroundColor,
});
final Color textColor;
final Color backgroundColor;
}
/// 테마별 색상 반환
AsciiThemeColors getThemeColors(AsciiColorTheme theme, Brightness brightness) {
return switch (theme) {
AsciiColorTheme.green => const AsciiThemeColors(
textColor: Color(0xFF00FF00),
backgroundColor: Color(0xFF0D0D0D),
),
AsciiColorTheme.amber => const AsciiThemeColors(
textColor: Color(0xFFFFB000),
backgroundColor: Color(0xFF1A1000),
),
AsciiColorTheme.white => const AsciiThemeColors(
textColor: Color(0xFFE0E0E0),
backgroundColor: Color(0xFF121212),
),
AsciiColorTheme.system =>
brightness == Brightness.dark
? const AsciiThemeColors(
textColor: Color(0xFFE0E0E0),
backgroundColor: Color(0xFF1E1E1E),
)
: const AsciiThemeColors(
textColor: Color(0xFF1E1E1E),
backgroundColor: Color(0xFFF5F5F5),
),
};
}
/// 몬스터 카테고리 (ASCII NEVER DIE 테마)
enum MonsterCategory {
/// 기본 버그 (Syntax Error, Type Mismatch 등)
bug,
/// 멀웨어 (Virus, Worm, Trojan 등)
malware,
/// 네트워크 위협 (Flood, DDoS, Injection 등)
network,
/// 시스템 위협 (Kernel, Memory, Buffer 등)
system,
/// 암호화/보안 (Encryption, Hash, Zero-Day 등)
crypto,
/// AI/ML 위협 (Neural Network, Machine Learning 등)
ai,
/// 보스 몬스터
boss,
}
/// 몬스터 이름으로 카테고리 결정 (ASCII NEVER DIE 테마)
MonsterCategory getMonsterCategory(String? monsterBaseName) {
if (monsterBaseName == null || monsterBaseName.isEmpty) {
return MonsterCategory.bug;
}
final name = monsterBaseName.toLowerCase();
// 보스 몬스터
if (name.startsWith('boss:') ||
name.contains('dragon') ||
name.contains('hydra') ||
name.contains('titan') ||
name.contains('leviathan') ||
name.contains('colossus') ||
name.contains('emperor') ||
name.contains('singularity') ||
name.contains('primordial') ||
name.contains('glitch god')) {
return MonsterCategory.boss;
}
// AI/ML 위협
if (name.contains('neural') ||
name.contains('ai ') ||
name.contains('deep') ||
name.contains('model') ||
name.contains('adversarial') ||
name.contains('training') ||
name.contains('federated') ||
name.contains('prompt') ||
name.contains('hallucination')) {
return MonsterCategory.ai;
}
// 암호화/보안 위협
if (name.contains('quantum') ||
name.contains('zero day') ||
name.contains('zero-day') ||
name.contains('crypto') ||
name.contains('hash') ||
name.contains('blockchain') ||
name.contains('homomorphic') ||
name.contains('zero knowledge') ||
name.contains('smart contract')) {
return MonsterCategory.crypto;
}
// 네트워크 위협
if (name.contains('flood') ||
name.contains('ddos') ||
name.contains('dos') ||
name.contains('injection') ||
name.contains('sql') ||
name.contains('xss') ||
name.contains('csrf') ||
name.contains('amplification') ||
name.contains('tunnel') ||
name.contains('shell') ||
name.contains('backdoor') ||
name.contains('c2') ||
name.contains('beacon')) {
return MonsterCategory.network;
}
// 시스템 위협
if (name.contains('kernel') ||
name.contains('memory') ||
name.contains('buffer') ||
name.contains('stack') ||
name.contains('heap') ||
name.contains('overflow') ||
name.contains('corruption') ||
name.contains('segfault') ||
name.contains('panic') ||
name.contains('rootkit') ||
name.contains('firmware') ||
name.contains('bootkit')) {
return MonsterCategory.system;
}
// 멀웨어
if (name.contains('virus') ||
name.contains('worm') ||
name.contains('trojan') ||
name.contains('ransomware') ||
name.contains('malware') ||
name.contains('botnet') ||
name.contains('cryptominer') ||
name.contains('keylogger') ||
name.contains('spyware') ||
name.contains('dropper') ||
name.contains('loader') ||
name.contains('payload')) {
return MonsterCategory.malware;
}
// 기본: 버그
return MonsterCategory.bug;
}
/// 버그 전투 애니메이션 (기본 버그 모양)
const battleAnimationBug = AsciiAnimationData(
frames: [
// 프레임 1: 대치
'''
o /\\_/\\
/|\\ ( o.o )
/ \\ > ^ <''',
// 프레임 2: 접근
'''
o /\\_/\\
/|\\ ( o.o )
/ \\ > ^ <''',
// 프레임 3: 공격 (근접)
'''
o_/ /\\_/\\
/| ( >.< )
/ \\ > ^ <''',
// 프레임 4: 히트
'''
o **** /\\_/\\
/|\\ *** ( X.X )
/ \\ > ~ <''',
// 프레임 5: 복귀 (승리 포즈)
'''
\\o/ /\\_/\\
/|\\ ( -.-)
/ \\ > ^ <''',
],
frameIntervalMs: 220,
);
/// 마을/상점 애니메이션 (8줄 x 40자 고정)
/// 캐릭터 위치: 머리=4, 몸통=5, 다리=6 (전투 애니메이션 기준)
const townAnimation = AsciiAnimationData(
frames: [
// 프레임 1: 상점 앞 대기
' ___________ \n'
' / SHOP \\ \n'
' | [======] | \n'
' | @@@@ | \n'
' | ITEMS | o \n'
' | | /|\\ \n'
' |___________| / \\ \n'
'~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~',
// 프레임 2: 이동 중
' ___________ \n'
' / SHOP \\ \n'
' | [======] | \n'
' | @@@@ | \n'
' | ITEMS | o \n'
' | | /|\\ \n'
' |___________| / \\ \n'
'~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~',
// 프레임 3: 거래 시작
' ___________ \n'
' / SHOP \\ \n'
' | [======] | \$ \n'
' | @@@@ | \$ \n'
' | ITEMS | o \$ \n'
' | | /|\\ \n'
' |___________| / \\ \n'
'~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~',
// 프레임 4: 거래 중
' ___________ \n'
' / SHOP \\ \n'
' | [<====>] | \$\$\$ \n'
' | @@@@ | \$\$\$ \n'
' | SOLD! | o \n'
' | | /|\\ \n'
' |___________| / \\ \n'
'~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~',
// 프레임 5: 거래 완료
' ___________ \n'
' / SHOP \\ \n'
' | [======] | + \n'
' | @@@@ | + \n'
' | ITEMS | \\o/ \n'
' | | /|\\ \n'
' |___________| / \\ \n'
'~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~',
],
frameIntervalMs: 280,
);
/// 걷는 애니메이션 (8줄 x 40자 고정)
const walkingAnimation = AsciiAnimationData(
frames: [
// 프레임 1: 양발 벌림
' \n'
' \n'
' \n'
' \n'
' o \n'
' /|\\ \n'
' / \\ \n'
'~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~',
// 프레임 2: 왼발 앞으로
' \n'
' \n'
' \n'
' \n'
' o \n'
' /|\\ \n'
' /| \n'
'~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~',
// 프레임 3: 두 발 모음
' \n'
' \n'
' \n'
' \n'
' o \n'
' /|\\ \n'
' || \n'
'~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~',
// 프레임 4: 오른발 앞으로
' \n'
' \n'
' \n'
' \n'
' o \n'
' /|\\ \n'
' |\\ \n'
'~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~',
// 프레임 5: 양발 벌림 (복귀)
' \n'
' \n'
' \n'
' \n'
' o \n'
' /|\\ \n'
' / \\ \n'
'~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~',
],
frameIntervalMs: 180,
);
/// 멀웨어 전투 애니메이션 (바이러스 모양)
const battleAnimationMalware = AsciiAnimationData(
frames: [
// 프레임 1: 대치
'''
o /\\_/\\
/|\\ ( o o )
/ \\ /|=====|\\''',
// 프레임 2: 접근
'''
o /\\_/\\
/|\\ ( o o )
/ \\ /|=====|\\''',
// 프레임 3: 공격 (근접)
'''
o_/ /\\_/\\
/| ( >.< )
/ \\ /|=====|\\''',
// 프레임 4: 히트
'''
o **** /\\_/\\
/|\\*** ( X X )
/ \\ /|=====|\\''',
// 프레임 5: 복귀 (승리 포즈)
'''
\\o/ /\\_/\\
/|\\ ( - - )
/ \\ /|=====|\\''',
],
frameIntervalMs: 220,
);
/// 네트워크 전투 애니메이션 (패킷 모양)
const battleAnimationNetwork = AsciiAnimationData(
frames: [
// 프레임 1: 대치
'''
o O
/|\\ /|\\
/ \\ / | \\''',
// 프레임 2: 접근
'''
o O
/|\\ /|\\
/ \\ / | \\''',
// 프레임 3: 공격 (근접)
'''
o_/ O
/| X|X
/ \\ / | \\''',
// 프레임 4: 히트
'''
o **** O
/|\\ *** X|X
/ \\ / | \\''',
// 프레임 5: 복귀 (승리 포즈)
'''
\\o/ O
/|\\ /|\\
/ \\ / | \\''',
],
frameIntervalMs: 220,
);
/// 시스템 전투 애니메이션 (커널 모양)
const battleAnimationSystem = AsciiAnimationData(
frames: [
// 프레임 1: 대치
'''
o .-.
/|\\ (o.o)
/ \\ |=|''',
// 프레임 2: 접근
'''
o .-.
/|\\ (o.o)
/ \\ |=|''',
// 프레임 3: 공격 (근접)
'''
o_/ .-.
/| (>.>)
/ \\ |=|''',
// 프레임 4: 히트
'''
o **** .-.
/|\\*** (X.X)
/ \\ |~|''',
// 프레임 5: 복귀 (승리 포즈)
'''
\\o/ .-.
/|\\ (-.-)
/ \\ |=|''',
],
frameIntervalMs: 250,
);
/// 암호화 전투 애니메이션 (자물쇠 모양)
const battleAnimationCrypto = AsciiAnimationData(
frames: [
// 프레임 1: 대치
'''
o __/\\__
/|\\ < (O)(O) >
/ \\ \\ \\/ /''',
// 프레임 2: 접근
'''
o __/\\__
/|\\ < (O)(O) >
/ \\ \\ \\/ /''',
// 프레임 3: 공격 (근접)
'''
o_/ __/\\__
/| < (X)(X) >
/ \\ \\ \\/ /''',
// 프레임 4: 히트
'''
o **** __/\\__
/|\\***< (X)(X) >
/ \\ \\ ~~ /''',
// 프레임 5: 복귀 (승리 포즈)
'''
\\o/ __/\\__
/|\\ < (-)(-)>
/ \\ \\ \\/ /''',
],
frameIntervalMs: 200,
);
/// AI 전투 애니메이션 (뉴럴넷 모양)
const battleAnimationAI = AsciiAnimationData(
frames: [
// 프레임 1: 대치
'''
o .---.
/|\\ ( o o )
/ \\ ~~~~~''',
// 프레임 2: 접근
'''
o .---.
/|\\ ( o o )
/ \\ ~~~~~''',
// 프레임 3: 공격 (근접)
'''
o_/ .---.
/| ( >.< )
/ \\ ~~~~~''',
// 프레임 4: 히트
'''
o **** .---.
/|\\** ( X X )
/ \\ ~~~~~''',
// 프레임 5: 복귀 (승리 포즈)
'''
\\o/ .---.
/|\\ ( - - )
/ \\ ~~~~~''',
],
frameIntervalMs: 280,
);
/// 보스 전투 애니메이션 (드래곤/보스 모양)
const battleAnimationBoss = AsciiAnimationData(
frames: [
// 프레임 1: 대치
'''
o /\\ /\\
/|\\ ( o V o )
/ \\ \\ ~~~ /''',
// 프레임 2: 접근
'''
o /\\ /\\
/|\\ ( o V o )
/ \\ \\ ~~~ /''',
// 프레임 3: 공격 (근접)
'''
o_/ /\\ /\\
/| ( X V X )
/ \\ \\ ~~~ /''',
// 프레임 4: 히트
'''
o ****/\\ /\\
/|\\** ( X V X )
/ \\ \\ ~~~ /''',
// 프레임 5: 복귀 (승리 포즈)
'''
\\o/ /\\ /\\
/|\\ ( - V - )
/ \\ \\ ~~~ /''',
],
frameIntervalMs: 200,
);
/// 몬스터 카테고리별 전투 애니메이션 반환
AsciiAnimationData getBattleAnimation(MonsterCategory category) {
return switch (category) {
MonsterCategory.bug => battleAnimationBug,
MonsterCategory.malware => battleAnimationMalware,
MonsterCategory.network => battleAnimationNetwork,
MonsterCategory.system => battleAnimationSystem,
MonsterCategory.crypto => battleAnimationCrypto,
MonsterCategory.ai => battleAnimationAI,
MonsterCategory.boss => battleAnimationBoss,
};
}
/// 레벨업 축하 애니메이션 (8줄 x 40자 고정)
/// 캐릭터 위치: 머리=4, 몸통=5, 다리=6 (전투 애니메이션 기준)
const levelUpAnimation = AsciiAnimationData(
frames: [
// 프레임 1: 시작
' * * * \n'
' * * * \n'
' * * \n'
' \n'
' * \\O/ * \n'
' /|\\ \n'
' * / \\ * \n'
'~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~',
// 프레임 2: 별 확산
' * * * \n'
' * * \n'
' * * \n'
' \n'
' * \\O/ * \n'
' /|\\ \n'
' * / \\ * \n'
'~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~',
// 프레임 3: 레벨업 텍스트
' * L E V E L U P ! * \n'
' * * \n'
' * * \n'
' \n'
' * \\O/ * \n'
' /|\\ \n'
' * / \\ * \n'
'~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~',
// 프레임 4: 빛나는 캐릭터
' * * * * * \n'
' * * \n'
' * * * * \n'
' \n'
' * \\O/ * \n'
' * /|\\ * \n'
' * / \\ * \n'
'~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~',
// 프레임 5: 마무리
' + \n'
' +++ \n'
' +++++ \n'
' \n'
' \\O/ \n'
' /|\\ \n'
' / \\ \n'
'~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~',
],
frameIntervalMs: 300,
);
/// 퀘스트 완료 애니메이션 (8줄 x 40자 고정)
/// 캐릭터 위치: 머리=4, 몸통=5, 다리=6 (전투 애니메이션 기준)
const questCompleteAnimation = AsciiAnimationData(
frames: [
// 프레임 1: 퀘스트 깃발
' [=======] \n'
' || || \n'
' || || \n'
' ||_____|| \n'
' \\O/ \n'
' /|\\ \n'
' / \\ \n'
'~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~',
// 프레임 2: 승리
' [QUEST!] \n'
' || || \n'
' || || \n'
' ||_____|| \n'
' \\\\O// \n'
' /|\\ \n'
' / \\ \n'
'~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~',
// 프레임 3: 보상
' COMPLETE! \n'
' \n'
' \n'
' \$\$\$ \n'
' \\O/ \$\$\$ \n'
' /|\\ \$\$\$ \n'
' / \\ \n'
'~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~',
// 프레임 4: 축하
' * * * * * \n'
' \n'
' \n'
' * * * * * \n'
' \\O/ +EXP \n'
' /|\\ +GOLD \n'
' / \\ \n'
'~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~',
// 프레임 5: 마무리
' [ VICTORY! ] \n'
' \n'
' \n'
' \n'
' \\O/ \n'
' /|\\ \n'
' / \\ \n'
'~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~',
],
frameIntervalMs: 350,
);
/// Act 완료 애니메이션 (8줄 x 40자 고정)
/// 캐릭터 위치: 머리=4, 몸통=5, 다리=6 (전투 애니메이션 기준)
const actCompleteAnimation = AsciiAnimationData(
frames: [
// 프레임 1: 커튼
' ______________________________ \n'
' | A C T | \n'
' | C O M P L E T E | \n'
' |______________________________| \n'
' \n'
' \n'
' \n'
'~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~',
// 프레임 2: 캐릭터 등장
' ______________________________ \n'
' | * * * * * | \n'
' | * * * * * | \n'
' |______________________________| \n'
' \\O/ \n'
' /|\\ \n'
' / \\ \n'
'~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~',
// 프레임 3: 플롯 진행 표시
' ______________________________ \n'
' | PROLOGUE --> ACT | \n'
' | STORY CONTINUES --> | \n'
' |______________________________| \n'
' \\O/ \n'
' /|\\ \n'
' / \\ \n'
'~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~',
// 프레임 4: 축하
' ______________________________ \n'
' | * * * * * | \n'
' | * * * * * | \n'
' |______________________________| \n'
' * \\O/ * \n'
' /|\\ \n'
' * / \\ * \n'
'~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~',
// 프레임 5: 마무리
' ______________________________ \n'
' | +---------+ | \n'
' | | NEXT | | \n'
' |____| CHAPTER |_______________| \n'
' \\O/ \n'
' /|\\ \n'
' / \\ \n'
'~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~',
],
frameIntervalMs: 400,
);
/// 부활 애니메이션 (8줄 x 40자 고정)
/// 어둠에서 빛으로, 캐릭터가 다시 일어남
const resurrectionAnimation = AsciiAnimationData(
frames: [
// 프레임 1: 어둠
' \n'
' . . . . . . \n'
' . . \n'
' . R . I . P . \n'
' . ___ . \n'
' . |___| . \n'
' . . . . . . \n'
'~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~',
// 프레임 2: 빛이 비침
' * \n'
' . .|. . . . \n'
' . | . \n'
' . | . \n'
' . _O_ . \n'
' . /___\\ . \n'
' . . . . . . \n'
'~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~',
// 프레임 3: 일어나는 중
' * * * \n'
' . | . . . \n'
' . | . \n'
' . O . \n'
' . /|\\ . \n'
' . | | . \n'
' . . . . . . \n'
'~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~',
// 프레임 4: 서있음
' * * * * * \n'
' \n'
' \n'
' O \n'
' /|\\ \n'
' / \\ \n'
' \n'
'~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~',
// 프레임 5: 부활 완료
' * RESURRECTED! * \n'
' \n'
' \n'
' \\O/ \n'
' /|\\ \n'
' / \\ \n'
' \n'
'~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~',
],
frameIntervalMs: 600, // 5프레임 × 600ms = 3초
);
/// 타입별 애니메이션 데이터 반환 (기본 전투는 bug)
AsciiAnimationData getAnimationData(AsciiAnimationType type) {
return switch (type) {
AsciiAnimationType.battle => battleAnimationBug,
AsciiAnimationType.town => townAnimation,
AsciiAnimationType.walking => walkingAnimation,
AsciiAnimationType.levelUp => levelUpAnimation,
AsciiAnimationType.questComplete => questCompleteAnimation,
AsciiAnimationType.actComplete => actCompleteAnimation,
AsciiAnimationType.resurrection => resurrectionAnimation,
};
}

View File

@@ -1,65 +0,0 @@
import 'package:asciineverdie/src/core/model/game_state.dart';
/// ASCII 애니메이션 타입 (TaskType과 매핑)
enum AsciiAnimationType {
/// 전투 장면 (캐릭터 vs 몬스터)
battle,
/// 마을/상점 장면
town,
/// 걷는 캐릭터
walking,
/// 레벨업 축하
levelUp,
/// 퀘스트 완료
questComplete,
/// Act 완료 (플롯 진행)
actComplete,
/// 부활 (사망 후)
resurrection,
}
// ============================================================================
// 특수 애니메이션 타이밍 상수
// ============================================================================
/// 특수 애니메이션 프레임 수
const specialAnimationFrameCounts = {
AsciiAnimationType.levelUp: 5,
AsciiAnimationType.questComplete: 4,
AsciiAnimationType.actComplete: 4,
AsciiAnimationType.resurrection: 5,
};
/// 특수 애니메이션 프레임 간격 (밀리초)
const specialAnimationFrameIntervals = {
AsciiAnimationType.levelUp: 300,
AsciiAnimationType.questComplete: 350,
AsciiAnimationType.actComplete: 400,
AsciiAnimationType.resurrection: 600,
};
/// 특수 애니메이션 총 지속 시간 (밀리초)
int getSpecialAnimationDuration(AsciiAnimationType type) {
final frames = specialAnimationFrameCounts[type] ?? 1;
final interval = specialAnimationFrameIntervals[type] ?? 200;
return frames * interval;
}
/// TaskType을 AsciiAnimationType으로 변환
AsciiAnimationType taskTypeToAnimation(TaskType taskType) {
return switch (taskType) {
TaskType.kill => AsciiAnimationType.battle,
TaskType.market ||
TaskType.sell ||
TaskType.buying => AsciiAnimationType.town,
TaskType.neutral ||
TaskType.load ||
TaskType.plot => AsciiAnimationType.walking,
};
}

View File

@@ -1,178 +0,0 @@
// 환경별 배경 패턴 데이터
// ASCII Patrol 스타일 - 패럴렉스 스크롤링 배경
import 'package:asciineverdie/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

@@ -1,92 +0,0 @@
// 배경 레이어 시스템 (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

@@ -1,265 +0,0 @@
import 'dart:ui' as ui;
import 'package:asciineverdie/src/core/animation/canvas/ascii_cell.dart';
import 'package:asciineverdie/src/core/animation/canvas/ascii_layer.dart';
import 'package:asciineverdie/src/core/constants/ascii_colors.dart';
import 'package:flutter/material.dart';
/// Paragraph 캐시 키
class _ParagraphCacheKey {
const _ParagraphCacheKey({
required this.char,
required this.color,
required this.fontSize,
required this.cellWidth,
required this.opacity,
});
final String char;
final AsciiCellColor color;
final double fontSize;
final double cellWidth;
final double opacity;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is _ParagraphCacheKey &&
char == other.char &&
color == other.color &&
fontSize == other.fontSize &&
cellWidth == other.cellWidth &&
opacity == other.opacity;
@override
int get hashCode => Object.hash(char, color, fontSize, cellWidth, opacity);
}
/// ASCII Canvas 페인터 (CustomPainter 구현)
///
/// 레이어 기반으로 ASCII 문자를 Canvas에 그린다.
/// 각 문자는 고정 크기 그리드 셀에 배치된다.
/// Paragraph 캐싱으로 GC 압박 최소화.
/// 테마 인식 색상 지원 (Phase 5).
class AsciiCanvasPainter extends CustomPainter {
AsciiCanvasPainter({
required this.layers,
this.gridWidth = 60,
this.gridHeight = 8,
this.backgroundColor = AsciiColors.background,
this.backgroundOpacity = 0.5,
this.layerVersion = 0,
this.objectColor = AsciiColors.object,
this.positiveColor = AsciiColors.positive,
this.negativeColor = AsciiColors.negative,
this.rarityUncommonColor = AsciiColors.rarityUncommon,
this.rarityRareColor = AsciiColors.rarityRare,
this.rarityEpicColor = AsciiColors.rarityEpic,
this.rarityLegendaryColor = AsciiColors.rarityLegendary,
});
/// 렌더링할 레이어 목록 (z-order 정렬 필요)
final List<AsciiLayer> layers;
/// 그리드 너비 (열 수)
final int gridWidth;
/// 그리드 높이 (행 수)
final int gridHeight;
/// 배경색 (테마 인식 가능)
final Color backgroundColor;
/// 배경 투명도 (0.0 ~ 1.0, 기본값 0.5 = 50%)
final double backgroundOpacity;
/// 레이어 버전 (변경 감지용)
final int layerVersion;
/// 오브젝트 색상 (테마 인식)
final Color objectColor;
/// 포지티브 이펙트 색상 (테마 인식)
final Color positiveColor;
/// 네거티브 이펙트 색상 (테마 인식)
final Color negativeColor;
// ═══════════════════════════════════════════════════════════════════════
// 무기 등급(ItemRarity) 색상 (Phase 9)
// ═══════════════════════════════════════════════════════════════════════
/// Uncommon 등급 색상 (초록)
final Color rarityUncommonColor;
/// Rare 등급 색상 (파랑)
final Color rarityRareColor;
/// Epic 등급 색상 (보라)
final Color rarityEpicColor;
/// Legendary 등급 색상 (금색)
final Color rarityLegendaryColor;
/// Paragraph 캐시 (문자+색상+크기 조합별)
static final Map<_ParagraphCacheKey, ui.Paragraph> _paragraphCache = {};
/// 캐시 크기 제한
static const int _maxCacheSize = 256;
/// 마지막 사용된 폰트 크기 (크기 변경 시 캐시 무효화)
static double _lastFontSize = 0;
@override
void paint(Canvas canvas, Size size) {
// 1. 셀 크기 계산
final cellWidth = size.width / gridWidth;
final cellHeight = size.height / gridHeight;
final fontSize = cellHeight * 0.85;
// 폰트 크기 변경 시 캐시 무효화
if ((fontSize - _lastFontSize).abs() > 0.5) {
_paragraphCache.clear();
_lastFontSize = fontSize;
}
// 2. 배경 채우기 (투명도 적용)
canvas.drawRect(
Rect.fromLTWH(0, 0, size.width, size.height),
Paint()..color = backgroundColor.withValues(alpha: backgroundOpacity),
);
// 3. 레이어별 렌더링 (z-order 순)
for (final layer in layers) {
_renderLayer(canvas, layer, cellWidth, cellHeight, fontSize);
}
}
/// 단일 레이어 렌더링
void _renderLayer(
Canvas canvas,
AsciiLayer layer,
double cellWidth,
double cellHeight,
double fontSize,
) {
for (var row = 0; row < layer.height; row++) {
for (var col = 0; col < layer.width; col++) {
final cell = layer.cells[row][col];
if (cell.isEmpty) continue;
// 오프셋 적용된 실제 그리드 위치
final gridX = col + layer.offsetX;
final gridY = row + layer.offsetY;
// 그리드 범위 확인
if (gridX < 0 || gridX >= gridWidth) continue;
if (gridY < 0 || gridY >= gridHeight) continue;
// 픽셀 좌표 계산
final x = gridX * cellWidth;
final y = gridY * cellHeight;
// 레이어 투명도를 셀 렌더링에 전달
_drawCell(
canvas,
cell,
x,
y,
cellWidth,
cellHeight,
fontSize,
layer.opacity,
);
}
}
}
/// 단일 셀 그리기 (캐시 활용)
void _drawCell(
Canvas canvas,
AsciiCell cell,
double x,
double y,
double cellWidth,
double cellHeight,
double fontSize,
double opacity,
) {
final cacheKey = _ParagraphCacheKey(
char: cell.char,
color: cell.color,
fontSize: fontSize,
cellWidth: cellWidth,
opacity: opacity,
);
// 캐시 히트 확인
var paragraph = _paragraphCache[cacheKey];
if (paragraph == null) {
// 캐시 미스: 새 Paragraph 생성 (opacity 적용)
final color = _getColor(cell.color).withValues(alpha: opacity);
final paragraphBuilder = ui.ParagraphBuilder(
ui.ParagraphStyle(
fontFamily: 'JetBrainsMono',
fontSize: fontSize,
textAlign: TextAlign.center,
height: 1.0,
),
);
paragraphBuilder.pushStyle(ui.TextStyle(color: color));
paragraphBuilder.addText(cell.char);
paragraph = paragraphBuilder.build();
paragraph.layout(ui.ParagraphConstraints(width: cellWidth));
// 캐시 크기 제한 (LRU 대신 단순 클리어)
if (_paragraphCache.length >= _maxCacheSize) {
_paragraphCache.clear();
}
_paragraphCache[cacheKey] = paragraph;
}
// 셀 중앙에 문자 배치
final offsetX = x + (cellWidth - paragraph.maxIntrinsicWidth) / 2;
final offsetY = y + (cellHeight - paragraph.height) / 2;
canvas.drawParagraph(paragraph, Offset(offsetX, offsetY));
}
/// AsciiCellColor를 Flutter Color로 변환 (테마 인식 색상 사용)
Color _getColor(AsciiCellColor cellColor) {
return switch (cellColor) {
AsciiCellColor.background => backgroundColor,
AsciiCellColor.object => objectColor,
AsciiCellColor.positive => positiveColor,
AsciiCellColor.negative => negativeColor,
// 무기 등급 색상 (Phase 9)
AsciiCellColor.rarityUncommon => rarityUncommonColor,
AsciiCellColor.rarityRare => rarityRareColor,
AsciiCellColor.rarityEpic => rarityEpicColor,
AsciiCellColor.rarityLegendary => rarityLegendaryColor,
};
}
@override
bool shouldRepaint(AsciiCanvasPainter oldDelegate) {
// 레이어 버전으로 빠른 비교
if (layerVersion != oldDelegate.layerVersion) return true;
// 동일 참조면 스킵
if (identical(layers, oldDelegate.layers)) return false;
// 그리드 설정 변경 확인
if (gridWidth != oldDelegate.gridWidth ||
gridHeight != oldDelegate.gridHeight) {
return true;
}
return true;
}
}

View File

@@ -1,79 +0,0 @@
import 'package:asciineverdie/src/core/animation/canvas/ascii_canvas_painter.dart';
import 'package:asciineverdie/src/core/animation/canvas/ascii_layer.dart';
import 'package:asciineverdie/src/core/constants/ascii_colors.dart';
import 'package:flutter/material.dart';
/// ASCII Canvas 위젯 (RepaintBoundary 포함)
///
/// AsciiCanvasPainter를 감싸는 위젯.
/// RepaintBoundary로 성능 최적화.
/// willChange를 애니메이션 상태에 따라 동적 설정.
/// 테마 인식 색상 자동 적용 (Phase 5).
class AsciiCanvasWidget extends StatelessWidget {
const AsciiCanvasWidget({
super.key,
required this.layers,
this.gridWidth = 60,
this.gridHeight = 8,
this.backgroundOpacity = 0.5,
this.isAnimating = true,
this.layerVersion = 0,
});
/// 렌더링할 레이어 목록
final List<AsciiLayer> layers;
/// 그리드 너비 (열 수)
final int gridWidth;
/// 그리드 높이 (행 수)
final int gridHeight;
/// 배경 투명도 (0.0 ~ 1.0, 기본값 0.5 = 50%)
final double backgroundOpacity;
/// 애니메이션 활성 상태 (willChange 최적화용)
final bool isAnimating;
/// 레이어 버전 (변경 감지용, shouldRepaint 최적화)
final int layerVersion;
@override
Widget build(BuildContext context) {
// 테마 인식 색상 (다크/라이트 모드 자동 전환)
final bgColor = AsciiColors.backgroundOf(context);
final objColor = AsciiColors.objectOf(context);
final posColor = AsciiColors.positiveOf(context);
final negColor = AsciiColors.negativeOf(context);
// 무기 등급 색상 (Phase 9)
final uncommonColor = AsciiColors.rarityUncommonOf(context);
final rareColor = AsciiColors.rarityRareOf(context);
final epicColor = AsciiColors.rarityEpicOf(context);
final legendaryColor = AsciiColors.rarityLegendaryOf(context);
return RepaintBoundary(
child: CustomPaint(
painter: AsciiCanvasPainter(
layers: layers,
gridWidth: gridWidth,
gridHeight: gridHeight,
backgroundColor: bgColor,
backgroundOpacity: backgroundOpacity,
layerVersion: layerVersion,
objectColor: objColor,
positiveColor: posColor,
negativeColor: negColor,
rarityUncommonColor: uncommonColor,
rarityRareColor: rareColor,
rarityEpicColor: epicColor,
rarityLegendaryColor: legendaryColor,
),
size: Size.infinite,
isComplex: true,
// 애니메이션 중일 때만 willChange 활성화
willChange: isAnimating,
),
);
}
}

View File

@@ -1,74 +0,0 @@
/// ASCII 셀 색상 (4색 팔레트 + 무기 등급 색상)
enum AsciiCellColor {
/// 배경색 (검정)
background,
/// 오브젝트 (흰색) - 캐릭터, 몬스터, 지형
object,
/// 포지티브 이펙트 (시안) - !, +, =, >, < / common 무기
positive,
/// 네거티브 이펙트 (마젠타) - *, ~
negative,
// ═══════════════════════════════════════════════════════════════════════
// 무기 등급(ItemRarity) 색상 (Phase 9)
// ═══════════════════════════════════════════════════════════════════════
/// Uncommon 등급 (초록)
rarityUncommon,
/// Rare 등급 (파랑)
rarityRare,
/// Epic 등급 (보라)
rarityEpic,
/// Legendary 등급 (금색)
rarityLegendary,
}
/// 단일 ASCII 셀 데이터
class AsciiCell {
const AsciiCell({required this.char, this.color = AsciiCellColor.object});
/// 표시할 문자 (단일 문자)
final String char;
/// 셀 색상 타입
final AsciiCellColor color;
/// 빈 셀 (투명)
static const empty = AsciiCell(char: ' ');
/// 문자가 공백인지 확인 (투명 처리용)
bool get isEmpty => char == ' ' || char.isEmpty;
/// 문자에서 색상 자동 결정
static AsciiCellColor colorFromChar(String char) {
// 포지티브 이펙트 문자 (시안)
if ('!+=><'.contains(char)) return AsciiCellColor.positive;
// 네거티브 이펙트 문자 (마젠타)
if ('*~'.contains(char)) return AsciiCellColor.negative;
// 기본 오브젝트 (흰색)
return AsciiCellColor.object;
}
/// 문자열에서 AsciiCell 생성 (자동 색상)
factory AsciiCell.fromChar(String char) {
if (char.isEmpty || char == ' ') return empty;
return AsciiCell(char: char, color: colorFromChar(char));
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is AsciiCell &&
runtimeType == other.runtimeType &&
char == other.char &&
color == other.color;
@override
int get hashCode => char.hashCode ^ color.hashCode;
}

View File

@@ -1,70 +0,0 @@
import 'package:asciineverdie/src/core/animation/canvas/ascii_cell.dart';
/// ASCII 레이어 데이터 구조 (Canvas 렌더러용)
///
/// 2D 셀 배열과 위치/깊이 정보를 담는다.
class AsciiLayer {
const AsciiLayer({
required this.cells,
this.zIndex = 0,
this.offsetX = 0,
this.offsetY = 0,
this.opacity = 1.0,
});
/// 2D 셀 배열 [row][column]
final List<List<AsciiCell>> cells;
/// Z 순서 (낮을수록 뒤쪽)
final int zIndex;
/// X 오프셋 (스크롤/이동용)
final int offsetX;
/// Y 오프셋
final int offsetY;
/// 레이어 투명도 (0.0 ~ 1.0, 배경 레이어 등에서 사용)
final double opacity;
/// 레이어 높이 (줄 수)
int get height => cells.length;
/// 레이어 너비 (열 수)
int get width => cells.isEmpty ? 0 : cells.first.length;
/// 특정 위치의 셀 반환 (범위 밖이면 empty)
AsciiCell getCell(int row, int col) {
if (row < 0 || row >= height) return AsciiCell.empty;
if (col < 0 || col >= cells[row].length) return AsciiCell.empty;
return cells[row][col];
}
/// 빈 레이어 생성
factory AsciiLayer.empty({int width = 60, int height = 8, int zIndex = 0}) {
final cells = List.generate(
height,
(_) => List.filled(width, AsciiCell.empty),
);
return AsciiLayer(cells: cells, zIndex: zIndex);
}
/// 문자열 리스트에서 레이어 생성 (자동 색상)
factory AsciiLayer.fromLines(
List<String> lines, {
int zIndex = 0,
int offsetX = 0,
int offsetY = 0,
}) {
final cells = lines.map((line) {
return line.split('').map(AsciiCell.fromChar).toList();
}).toList();
return AsciiLayer(
cells: cells,
zIndex: zIndex,
offsetX: offsetX,
offsetY: offsetY,
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,175 +0,0 @@
import 'package:asciineverdie/src/core/animation/ascii_animation_type.dart';
import 'package:asciineverdie/src/core/animation/canvas/ascii_cell.dart';
import 'package:asciineverdie/src/core/animation/canvas/ascii_layer.dart';
/// Canvas용 특수 이벤트 애니메이션 합성기
///
/// 레벨업, 퀘스트 완료, Act 완료, 부활 등
class CanvasSpecialComposer {
const CanvasSpecialComposer();
/// 프레임 상수
static const int frameWidth = 60;
static const int frameHeight = 8;
/// 레이어 기반 프레임 생성
List<AsciiLayer> composeLayers(
AsciiAnimationType type,
int frameIndex,
int globalTick,
) {
return switch (type) {
AsciiAnimationType.levelUp => _composeLevelUp(frameIndex, globalTick),
AsciiAnimationType.questComplete => _composeQuestComplete(
frameIndex,
globalTick,
),
AsciiAnimationType.actComplete => _composeActComplete(
frameIndex,
globalTick,
),
AsciiAnimationType.resurrection => _composeResurrection(
frameIndex,
globalTick,
),
_ => [AsciiLayer.empty()],
};
}
/// 레벨업 애니메이션
List<AsciiLayer> _composeLevelUp(int frameIndex, int globalTick) {
final layers = <AsciiLayer>[
_createEffectBackground(globalTick, '*'),
_createCenteredSprite(_levelUpFrames[frameIndex % _levelUpFrames.length]),
];
return layers;
}
/// 퀘스트 완료 애니메이션
List<AsciiLayer> _composeQuestComplete(int frameIndex, int globalTick) {
final layers = <AsciiLayer>[
_createEffectBackground(globalTick, '+'),
_createCenteredSprite(
_questCompleteFrames[frameIndex % _questCompleteFrames.length],
),
];
return layers;
}
/// Act 완료 애니메이션
List<AsciiLayer> _composeActComplete(int frameIndex, int globalTick) {
final layers = <AsciiLayer>[
_createEffectBackground(globalTick, '~'),
_createCenteredSprite(
_actCompleteFrames[frameIndex % _actCompleteFrames.length],
),
];
return layers;
}
/// 부활 애니메이션
List<AsciiLayer> _composeResurrection(int frameIndex, int globalTick) {
final layers = <AsciiLayer>[
_createEffectBackground(globalTick, '.'),
_createCenteredSprite(
_resurrectionFrames[frameIndex % _resurrectionFrames.length],
),
];
return layers;
}
/// 이펙트 배경 레이어 생성 (z=0)
AsciiLayer _createEffectBackground(int globalTick, String effectChar) {
final cells = List.generate(
frameHeight,
(_) => List.filled(frameWidth, AsciiCell.empty),
);
// 반짝이는 이펙트
for (var y = 0; y < frameHeight; y++) {
for (var x = 0; x < frameWidth; x++) {
final offset = (x + y + globalTick) % 8;
if (offset == 0) {
cells[y][x] = AsciiCell.fromChar(effectChar);
}
}
}
return AsciiLayer(cells: cells, zIndex: 0);
}
/// 중앙 정렬 스프라이트 레이어 생성 (z=1)
AsciiLayer _createCenteredSprite(List<String> lines) {
final cells = _spriteToCells(lines);
// 중앙 정렬
final spriteWidth = lines.isEmpty ? 0 : lines[0].length;
final offsetX = (frameWidth - spriteWidth) ~/ 2;
final offsetY = (frameHeight - cells.length) ~/ 2;
return AsciiLayer(
cells: cells,
zIndex: 1,
offsetX: offsetX,
offsetY: offsetY,
);
}
/// 문자열 스프라이트를 AsciiCell 2D 배열로 변환
List<List<AsciiCell>> _spriteToCells(List<String> lines) {
return lines.map((line) {
return line.split('').map(AsciiCell.fromChar).toList();
}).toList();
}
}
// ============================================================================
// 레벨업 프레임 (5프레임)
// ============================================================================
const _levelUpFrames = [
[r' * ', r' \|/ ', r' o ', r' /|\ ', r' / \ '],
[r' * * ', r' \|/ ', r' O ', r' </|\> ', r' / \ '],
[r' * * * ', r' \|/ ', r' O ', r' <\|/> ', r' / \ '],
[r' * * * * ', r' LEVEL ', r' UP! ', r' \O/ ', r' / \ '],
[r'* * * * *', r' LEVEL ', r' UP! ', r' \O/ ', r' | | '],
];
// ============================================================================
// 퀘스트 완료 프레임 (4프레임)
// ============================================================================
const _questCompleteFrames = [
[r' [?] ', r' | ', r' o ', r' /|\ ', r' / \ '],
[r' [???] ', r' | ', r' o! ', r' /|\ ', r' / \ '],
[r' [DONE] ', r' ! ', r' \o/ ', r' | ', r' / \ '],
[r' +[DONE]+', r' \!/ ', r' \o/ ', r' | ', r' / \ '],
];
// ============================================================================
// Act 완료 프레임 (4프레임)
// ============================================================================
const _actCompleteFrames = [
[r'=========', r' ACT ', r' CLEAR ', r' o ', r' /|\ '],
[r'~~~~~~~~~', r' ACT ', r' CLEAR! ', r' \o/ ', r' | '],
[r'*~*~*~*~*', r' ACT ', r' CLEAR!! ', r' \O/ ', r' / \ '],
[r'*********', r' ACT ', r' CLEAR!! ', r' \O/ ', r' | | '],
];
// ============================================================================
// 부활 프레임 (5프레임)
// ============================================================================
const _resurrectionFrames = [
// 프레임 1: R.I.P 묘비
[r' ___ ', r' |RIP| ', r' | | ', r'__|___|__'],
// 프레임 2: 빛 내림
[r' \|/ ', r' -|R|- ', r' | | ', r'__|___|__'],
// 프레임 3: 일어남
[r' \o/ ', r' --|-- ', r' | | ', r'__|___|__'],
// 프레임 4: 서있음
[r' o ', r' /|\ ', r' / \ ', r'_________'],
// 프레임 5: 부활 완료
[r' REVIVED ', r' \o/ ', r' | ', r'___/ \___'],
];

View File

@@ -1,124 +0,0 @@
import 'package:asciineverdie/src/core/animation/canvas/ascii_cell.dart';
import 'package:asciineverdie/src/core/animation/canvas/ascii_layer.dart';
import 'package:asciineverdie/src/core/animation/race_character_frames.dart';
/// Canvas용 마을/상점 애니메이션 합성기
///
/// 마을 배경 + 상점 건물 + 캐릭터
/// Phase 4: 종족별 캐릭터 프레임 지원
class CanvasTownComposer {
const CanvasTownComposer({this.raceId});
/// 종족 ID (종족별 캐릭터 프레임 선택용)
final String? raceId;
/// 프레임 상수
static const int frameWidth = 60;
static const int frameHeight = 8;
/// 레이어 기반 프레임 생성
List<AsciiLayer> composeLayers(int globalTick) {
return [
_createBackgroundLayer(),
_createShopLayer(),
_createCharacterLayer(globalTick),
];
}
/// 마을 배경 레이어 생성 (z=0)
AsciiLayer _createBackgroundLayer() {
final cells = List.generate(
frameHeight,
(_) => List.filled(frameWidth, AsciiCell.empty),
);
// 하늘 (상단 2줄)
for (var x = 0; x < frameWidth; x++) {
// 별/구름 패턴
if (x % 12 == 3) {
cells[0][x] = AsciiCell.fromChar('*');
}
if (x % 8 == 5) {
cells[1][x] = AsciiCell.fromChar('~');
}
}
// 바닥 (하단 1줄)
for (var x = 0; x < frameWidth; x++) {
cells[7][x] = AsciiCell.fromChar('=');
}
return AsciiLayer(cells: cells, zIndex: 0);
}
/// 상점 건물 레이어 생성 (z=1)
AsciiLayer _createShopLayer() {
const shopLines = [
r' ___________ ',
r' / SHOP \ ',
r' | _______ | ',
r' | | | | ',
r' | | $$ | || ',
r'___|_|_______|__||____',
];
final cells = _spriteToCells(shopLines);
// 상점 위치 (오른쪽)
const shopX = 32;
final shopY = frameHeight - cells.length - 1;
return AsciiLayer(cells: cells, zIndex: 1, offsetX: shopX, offsetY: shopY);
}
/// 캐릭터 레이어 생성 (z=2)
/// Phase 4: 종족별 프레임 지원
AsciiLayer _createCharacterLayer(int globalTick) {
final frameIndex = globalTick % 4; // 4프레임 루프
List<String> charFrame;
// 종족별 프레임 사용 시도
if (raceId != null && raceId!.isNotEmpty) {
final raceData = RaceCharacterFrames.get(raceId!);
if (raceData != null) {
// idle 프레임 직접 사용 (마을에서는 서있는 자세)
final idleFrame = raceData.idle[frameIndex % raceData.idle.length];
charFrame = idleFrame.lines;
} else {
charFrame = _shopIdleFrames[frameIndex];
}
} else {
charFrame = _shopIdleFrames[frameIndex];
}
final cells = _spriteToCells(charFrame);
// 상점 앞에 캐릭터 배치
const charX = 25;
final charY = frameHeight - cells.length - 1;
return AsciiLayer(cells: cells, zIndex: 2, offsetX: charX, offsetY: charY);
}
/// 문자열 스프라이트를 AsciiCell 2D 배열로 변환
List<List<AsciiCell>> _spriteToCells(List<String> lines) {
return lines.map((line) {
return line.split('').map(AsciiCell.fromChar).toList();
}).toList();
}
}
// ============================================================================
// 상점 앞 대기 프레임 (4프레임 루프) - 물건 보는 동작
// ============================================================================
const _shopIdleFrames = [
// 프레임 1: 기본
[r' o ', r' /|\ ', r' / \ '],
// 프레임 2: 머리 숙임
[r' o ', r' /|~ ', r' / \ '],
// 프레임 3: 물건 보기
[r' o? ', r' /| ', r' / \ '],
// 프레임 4: 고개 끄덕
[r' o! ', r' /|\ ', r' / \ '],
];

View File

@@ -1,120 +0,0 @@
import 'package:asciineverdie/src/core/animation/background_data.dart';
import 'package:asciineverdie/src/core/animation/background_layer.dart';
import 'package:asciineverdie/src/core/animation/canvas/ascii_cell.dart';
import 'package:asciineverdie/src/core/animation/canvas/ascii_layer.dart';
import 'package:asciineverdie/src/core/animation/race_character_frames.dart';
/// Canvas용 걷기 애니메이션 합성기
///
/// 배경 스크롤 + 걷는 캐릭터
/// Phase 4: 종족별 캐릭터 프레임 지원
class CanvasWalkingComposer {
const CanvasWalkingComposer({this.raceId});
/// 종족 ID (종족별 캐릭터 프레임 선택용)
final String? raceId;
/// 프레임 상수
static const int frameWidth = 60;
static const int frameHeight = 8;
/// 레이어 기반 프레임 생성
List<AsciiLayer> composeLayers(int globalTick) {
return [
_createBackgroundLayer(globalTick),
_createCharacterLayer(globalTick),
];
}
/// 배경 레이어 생성 (z=0) - 숲 환경 기본
AsciiLayer _createBackgroundLayer(int globalTick) {
final cells = List.generate(
frameHeight,
(_) => List.filled(frameWidth, AsciiCell.empty),
);
final bgLayers = getBackgroundLayers(EnvironmentType.forest);
for (final layer in bgLayers) {
// 스크롤 오프셋 계산 (걷기는 더 빠른 스크롤)
final offset = (globalTick * layer.scrollSpeed * 2).toInt();
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;
for (var x = 0; x < frameWidth; x++) {
final patternIdx = (x + offset) % pattern.length;
final char = pattern[patternIdx];
if (char != ' ') {
cells[y][x] = AsciiCell.fromChar(char);
}
}
}
}
return AsciiLayer(cells: cells, zIndex: 0);
}
/// 걷는 캐릭터 레이어 생성 (z=1)
/// Phase 4: 종족별 프레임 지원
AsciiLayer _createCharacterLayer(int globalTick) {
final frameIndex = globalTick % 4; // 4프레임 루프
List<String> charFrame;
// 종족별 프레임 사용 시도
if (raceId != null && raceId!.isNotEmpty) {
final raceData = RaceCharacterFrames.get(raceId!);
if (raceData != null) {
// idle 프레임을 기반으로 걷기 애니메이션 생성
final idleFrame = raceData.idle[frameIndex % raceData.idle.length];
charFrame = _animateWalking(idleFrame.lines, frameIndex);
} else {
charFrame = _walkingFrames[frameIndex];
}
} else {
charFrame = _walkingFrames[frameIndex];
}
final cells = _spriteToCells(charFrame);
// 화면 중앙에 캐릭터 배치 (25% 위치)
const charX = 15;
// 바닥 레이어(Y=7) 위에 서있도록
final charY = frameHeight - cells.length - 1;
return AsciiLayer(cells: cells, zIndex: 1, offsetX: charX, offsetY: charY);
}
/// idle 프레임 기반 걷기 애니메이션 생성
/// 종족별 다리 모양을 유지 (idle 프레임이 4개라 자연스럽게 변화)
List<String> _animateWalking(List<String> idleLines, int frameIndex) {
// idle 프레임을 그대로 사용 (종족별 다리 모양 유지)
// frameIndex에 따라 idle[0~3] 중 하나가 선택되어 자연스럽게 애니메이션됨
return idleLines;
}
/// 문자열 스프라이트를 AsciiCell 2D 배열로 변환
List<List<AsciiCell>> _spriteToCells(List<String> lines) {
return lines.map((line) {
return line.split('').map(AsciiCell.fromChar).toList();
}).toList();
}
}
// ============================================================================
// 걷기 프레임 (4프레임 루프)
// ============================================================================
const _walkingFrames = [
// 프레임 1: 오른발 앞
[r' o ', r' /|\ ', r' /| '],
// 프레임 2: 모음
[r' o ', r' /|\ ', r' |\ '],
// 프레임 3: 왼발 앞
[r' o ', r' /|\ ', r' /| '],
// 프레임 4: 모음
[r' o ', r' /|\ ', r' |\ '],
];

View File

@@ -1,19 +0,0 @@
import 'package:asciineverdie/src/core/animation/canvas/ascii_cell.dart';
import 'package:asciineverdie/src/core/model/item_stats.dart';
/// 아이템 희귀도와 애니메이션 색상 간의 매핑
///
/// Clean Architecture 준수를 위해 model(ItemRarity) → animation(AsciiCellColor)
/// 의존성을 animation 레이어에서 처리
extension ItemRarityColorMapper on ItemRarity {
/// 공격 이펙트 셀 색상 (Phase 9: 무기 등급별 이펙트)
///
/// common은 기본 positive(시안), 나머지는 등급별 고유 색상
AsciiCellColor get effectCellColor => switch (this) {
ItemRarity.common => AsciiCellColor.positive,
ItemRarity.uncommon => AsciiCellColor.rarityUncommon,
ItemRarity.rare => AsciiCellColor.rarityRare,
ItemRarity.epic => AsciiCellColor.rarityEpic,
ItemRarity.legendary => AsciiCellColor.rarityLegendary,
};
}

View File

@@ -1,128 +0,0 @@
// 캐릭터 애니메이션 프레임 (8줄 Stone Story RPG 스타일)
// 참조: Stone Story RPG - 상세하고 생동감 있는 ASCII 아트
/// 전투 페이즈
enum BattlePhase {
/// 대치 상태 (기본)
idle,
/// 공격 준비
prepare,
/// 공격 중
attack,
/// 피격 (몬스터가 맞음)
hit,
/// 복귀
recover,
}
/// 공격자 타입 (위치 계산용)
enum AttackerType {
/// 공격 없음 (idle 상태)
none,
/// 플레이어가 공격
player,
/// 몬스터가 공격
monster,
/// 동시 공격 (양쪽 모두 이동)
both,
}
/// 캐릭터 프레임 데이터
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' o_ ', r' \| ', r' / \ ']),
CharacterFrame([r' o/ ', r' \| ', r' / \ ']),
];
// ============================================================================
// 공격 프레임 (전진 + 휘두르기) - 5프레임, 심플 3줄 스타일
// 구조: [머리+공격, 몸통+팔, 다리]
// 수정: 공격 이펙트를 머리 줄로 통일 (1칸 위로)
// ============================================================================
const _attackFrames = [
CharacterFrame([r' o\ ', r' /| ', r' / \ ']),
CharacterFrame([r' o- ', r' /| ', r' / \ ']),
CharacterFrame([r' o-- ', r' /| ', r' / \ ']),
CharacterFrame([r' o-=>', r' /| ', r' / \ ']),
CharacterFrame([r' o ', r' /|\ ', r' / \ ']),
];
// ============================================================================
// 히트 프레임 (공격 명중) - 3프레임, 심플 3줄 스타일
// 구조: [머리+이펙트, 몸통+팔, 다리]
// 수정: 히트 이펙트를 머리 줄로 통일 (1칸 위로)
// ============================================================================
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

@@ -1,192 +0,0 @@
// 프론트 화면용 Hero vs Glitch God ASCII 애니메이션 데이터
// 작은 용사(3줄)가 거대한 Glitch God(10줄)에 맞서는 장면
// 캐릭터 해부학: 머리(1개), 양팔(2개), 양다리(2개)
/// 애니메이션 프레임 (10줄, 12프레임 루프)
const frontScreenAnimationFrames = [
// ========================================================================
// 프레임 0: 대치 상태 1 (숨쉬기 - 들숨)
// ========================================================================
'''
░░▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░░
░▓▓ G L I T C H ▓▓░
░▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░
░▓▓▓ ◈◈ ◈◈ ▓▓▓░
░▓▓▓▓ ▼▼▼ ▓▓▓▓░
░▓▓▓▓▓ ████████ ▓▓▓▓▓░
o ░▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░
/|\\ ░▓▓▓ G O D ▓▓▓░
/ \\ ░▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░
~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~''',
// ========================================================================
// 프레임 1: 대치 상태 2 (숨쉬기 - 날숨)
// ========================================================================
'''
░░▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░░
░▓▓ G L I T C H ▓▓░
░▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░
░▓▓▓ ◉◉ ◉◉ ▓▓▓░
░▓▓▓▓ ~~~ ▓▓▓▓░
░▓▓▓▓▓ ████████ ▓▓▓▓▓░
O ░▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░
/|\\ ░▓▓▓ G O D ▓▓▓░
| | ░▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░
~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~''',
// ========================================================================
// 프레임 2: 용사 전진 1
// ========================================================================
'''
▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
▓▓ G L I T C H ▓▓
▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
▓▓▓▓ ◈◈ ◈◈ ▓▓▓▓
▓▓▓▓▓ ▼▼▼ ▓▓▓▓▓
o ▓▓▓▓▓▓ ████████ ▓▓▓▓▓▓
/|\\ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
/ \\ ▓▓▓▓ G O D ▓▓▓▓
▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~''',
// ========================================================================
// 프레임 3: 용사 전진 2
// ========================================================================
'''
▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
▓▓ G L I T C H ▓▓
▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
▓▓▓▓ ◉◉ ◉◉ ▓▓▓▓
▓▓▓▓▓ ~~~ ▓▓▓▓▓
o ▓▓▓▓▓▓ ████████ ▓▓▓▓▓▓
/|\\ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
| | ▓▓▓▓ G O D ▓▓▓▓
▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~''',
// ========================================================================
// 프레임 4: 공격 준비 1 (무기 들기)
// ========================================================================
'''
▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒
▒▓ G L I T C H ▓▒
▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒
▒▓▓▓ ◈◈ ◈◈ ▓▓▓▒
▒▓▓▓▓ ▼▼▼ ▓▓▓▓▒
o ▒▓▓▓▓▓ ████████ ▓▓▓▓▓▒
\\| ▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒
/ \\ ▒▓▓▓ G O D ▓▓▓▒
▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒
~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~''',
// ========================================================================
// 프레임 5: 공격 준비 2 (무기 뒤로)
// ========================================================================
'''
▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒
▒▓ G L I T C H ▓▒
▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒
▒▓▓▓ ◉◉ ◉◉ ▓▓▓▒
▒▓▓▓▓ ~~~ ▓▓▓▓▒
o ▒▓▓▓▓▓ ████████ ▓▓▓▓▓▒
\\|/ ▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒
/ \\ ▒▓▓▓ G O D ▓▓▓▒
▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒
~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~''',
// ========================================================================
// 프레임 6: 공격 1 (휘두르기)
// ========================================================================
'''
░▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░
░▓ G L I T C H ▓░
░▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░
░▓▓▓ ◈◈ ◈◈ ▓▓▓░
░▓▓▓▓ ▼▼▼ ▓▓▓▓░
o ░▓▓▓▓▓ ████████ ▓▓▓▓▓░
\\|- ░▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░
/ \\ ░▓▓▓ G O D ▓▓▓░
░▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░
~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~''',
// ========================================================================
// 프레임 7: 공격 2 (이펙트 + 히트)
// ========================================================================
'''
░▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░
░▓ G#L@I*T&C!H ▓░
░▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░
░▓▓▓ X X X X ▓▓▓░
░▓▓▓▓ !!! ▓▓▓▓░
o ░▓▓▓▓▓ ████████ ▓▓▓▓▓░
\\|-=>* ░▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░
/ \\ ░▓▓▓ G O D ▓▓▓░
░▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░
~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~''',
// ========================================================================
// 프레임 8: 보스 반격 준비 1
// ========================================================================
'''
▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
▓▓ G L I T C H ▓▓
▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
▓▓▓▓ @@ @@ ▓▓▓▓
▓▓▓▓▓ <=== ▓▓▓▓▓
o ▓▓▓▓▓▓ ████████ ▓▓▓▓▓▓
/|\\ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
/ \\ ▓▓▓▓ G O D ▓▓▓▓
▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~''',
// ========================================================================
// 프레임 9: 보스 반격 2 (글리치 증가)
// ========================================================================
'''
▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒
▒▓ G L I T C H ▓▒
▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒
▒▓▓▓ ◉◉ ◉◉ ▓▓▓▒
▒▓▓▓▓ <====== ▓▓▓▓▒
o ▒▓▓▓▓▓ ████████ ▓▓▓▓▓▒
/|\\ ▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒
/ \\ ▒▓▓▓ G O D ▓▓▓▒
▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒
~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~''',
// ========================================================================
// 프레임 10: 방어 1 (방패 들기)
// ========================================================================
'''
▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
▓▓ G L I T C H ▓▓
▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
▓▓▓▓ @@ @@ ▓▓▓▓
▓▓▓▓▓ <==== ▓▓▓▓▓
o[] ▓▓▓▓▓▓ ████████ ▓▓▓▓▓▓
/| ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
/ \\ ▓▓▓▓ G O D ▓▓▓▓
▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~''',
// ========================================================================
// 프레임 11: 방어 2 (충격파 막기)
// ========================================================================
'''
▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒
▒▓ G L I T C H ▓▒
▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒
▒▓▓▓ ◉◉ ◉◉ ▓▓▓▒
▒▓▓▓▓ <===== ▓▓▓▓▒
o[]* ▒▓▓▓▓▓ ████████ ▓▓▓▓▓▒
/| ▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒
/ \\ ▒▓▓▓ G O D ▓▓▓▒
▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒
~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~''',
];
/// 애니메이션 프레임 간격 (밀리초)
const frontScreenAnimationIntervalMs = 350;
/// 애니메이션 총 프레임 수
const frontScreenAnimationFrameCount = 12;

View File

@@ -1,105 +0,0 @@
// 몬스터 크기 시스템
// Act와 몬스터 등급에 따라 ASCII 아트 크기 결정
import 'package:asciineverdie/src/core/model/monster_grade.dart';
import 'package:asciineverdie/src/core/util/deterministic_random.dart';
/// 몬스터 크기 enum
/// 실제 프레임 줄 수와 일치하도록 설정
enum MonsterSize {
/// 2줄 (레벨 1-5)
tiny(2),
/// 4줄 (레벨 6-10)
small(4),
/// 6줄 (레벨 11-15)
medium(6),
/// 8줄 (레벨 16-25)
large(8),
/// 8줄 (레벨 26-35)
huge(8),
/// 8줄 (레벨 36-50)
giant(8),
/// 8줄 (레벨 51+, 보스급)
titanic(8);
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;
}
// =============================================================================
// Act 기반 몬스터 사이즈 결정 (Phase 13)
// =============================================================================
/// 보스 몬스터 사이즈 결정 (Act별 고정)
///
/// - Prologue(1), Act I(2): small (4줄)
/// - Act II(3), Act III(4): medium (6줄)
/// - Act IV(5), Act V(6)+: large (8줄)
MonsterSize getBossSizeForAct(int plotStageCount) {
if (plotStageCount <= 2) return MonsterSize.small;
if (plotStageCount <= 4) return MonsterSize.medium;
return MonsterSize.large;
}
/// 일반/엘리트 몬스터 사이즈 결정 (Act별 확률 랜덤)
///
/// Act가 진행될수록 큰 몬스터가 나올 확률 증가.
/// 프롤로그에서는 대형 몬스터 0%.
MonsterSize getRandomSizeForAct(int plotStageCount, DeterministicRandom rng) {
final roll = rng.nextInt(100);
// Act별 확률 테이블 (small %, medium %, large = 나머지)
final (smallChance, mediumChance) = switch (plotStageCount) {
<= 1 => (70, 30), // Prologue: 70% small, 30% medium, 0% large
2 => (50, 40), // Act I: 50% small, 40% medium, 10% large
3 => (35, 40), // Act II: 35% small, 40% medium, 25% large
4 => (25, 40), // Act III: 25% small, 40% medium, 35% large
5 => (15, 35), // Act IV: 15% small, 35% medium, 50% large
_ => (10, 25), // Act V+: 10% small, 25% medium, 65% large
};
if (roll < smallChance) return MonsterSize.small;
if (roll < smallChance + mediumChance) return MonsterSize.medium;
return MonsterSize.large;
}
/// 몬스터 등급과 Act에 따른 사이즈 결정 (통합 함수)
///
/// 보스: Act별 고정 사이즈
/// 일반/엘리트: Act별 확률 랜덤
MonsterSize getMonsterSizeForAct({
required int plotStageCount,
required MonsterGrade grade,
required DeterministicRandom rng,
}) {
if (grade == MonsterGrade.boss) {
return getBossSizeForAct(plotStageCount);
}
return getRandomSizeForAct(plotStageCount, rng);
}

View File

@@ -1,754 +0,0 @@
// 종족별 ASCII 캐릭터 프레임 데이터
// 모든 캐릭터는 3줄 × 6자 폭으로 통일 (보스 10줄과 대비)
import 'package:asciineverdie/src/core/animation/character_frames.dart';
/// 종족별 캐릭터 프레임 저장소
class RaceCharacterFrames {
RaceCharacterFrames._();
/// 종족 ID로 프레임 데이터 조회
static RaceFrameData? get(String raceId) => _raceFrames[raceId];
/// 기본 프레임 (종족 미지정 시)
static RaceFrameData get defaultFrames => _raceFrames['byte_human']!;
/// 모든 종족 ID 목록
static List<String> get allRaceIds => _raceFrames.keys.toList();
}
/// 종족별 프레임 데이터
class RaceFrameData {
const RaceFrameData({
required this.raceId,
required this.idle,
required this.prepare,
required this.attack,
required this.hit,
required this.recover,
});
final String raceId;
final List<CharacterFrame> idle; // 4 프레임
final List<CharacterFrame> prepare; // 3 프레임
final List<CharacterFrame> attack; // 5 프레임
final List<CharacterFrame> hit; // 3 프레임
final List<CharacterFrame> recover; // 3 프레임
/// BattlePhase로 프레임 목록 조회
List<CharacterFrame> getFrames(BattlePhase phase) {
return switch (phase) {
BattlePhase.idle => idle,
BattlePhase.prepare => prepare,
BattlePhase.attack => attack,
BattlePhase.hit => hit,
BattlePhase.recover => recover,
};
}
}
// ============================================================================
// 종족별 프레임 데이터 (21개 종족)
// 구조: [머리, 몸통+팔, 다리] - 모두 6자 폭
// ============================================================================
const _raceFrames = <String, RaceFrameData>{
// --------------------------------------------------------------------------
// 기본형 (Byte Human, Buffer Dwarf)
// --------------------------------------------------------------------------
'byte_human': RaceFrameData(
raceId: 'byte_human',
idle: [
CharacterFrame([r' o ', r' /|\ ', r' / \ ']),
CharacterFrame([r' o ', r' /|\ ', r' | | ']),
CharacterFrame([r' o ', r' /|\ ', r' / \ ']),
CharacterFrame([r' O ', r' /|\ ', r' / \ ']),
],
prepare: [
CharacterFrame([r' o ', r' \|\ ', r' / \ ']),
CharacterFrame([r' o ', r' \| ', r' / \ ']),
CharacterFrame([r' o ', r' \|/ ', r' / \ ']),
],
attack: [
CharacterFrame([r' o ', r' \| ', r' / \ ']),
CharacterFrame([r' o ', r' \|- ', r' / \ ']),
CharacterFrame([r' o ', r' \|-- ', r' / \ ']),
CharacterFrame([r' o ', r'\|-=> ', r' / \ ']),
CharacterFrame([r' o ', r' /|\ ', r' / \ ']),
],
hit: [
CharacterFrame([r' o ', r'/|\* ', r' / \ ']),
CharacterFrame([r' o ', r'/|\ * ', r' / \ ']),
CharacterFrame([r' o ', r' /|\ ', r' / \ ']),
],
recover: [
CharacterFrame([r' o ', r' /|\ ', r' | ']),
CharacterFrame([r' o ', r' /|\ ', r' / \ ']),
CharacterFrame([r' o ', r' /|\ ', r' / \ ']),
],
),
'buffer_dwarf': RaceFrameData(
raceId: 'buffer_dwarf',
idle: [
CharacterFrame([r' o ', r' /█\ ', r' / \ ']),
CharacterFrame([r' o ', r' /█\ ', r' | | ']),
CharacterFrame([r' o ', r' /█\ ', r' / \ ']),
CharacterFrame([r' O ', r' /█\ ', r' / \ ']),
],
prepare: [
CharacterFrame([r' o ', r' \█\ ', r' / \ ']),
CharacterFrame([r' o ', r' \█ ', r' / \ ']),
CharacterFrame([r' o ', r' \█/ ', r' / \ ']),
],
attack: [
CharacterFrame([r' o ', r' \█ ', r' / \ ']),
CharacterFrame([r' o ', r' \█- ', r' / \ ']),
CharacterFrame([r' o ', r' \█-- ', r' / \ ']),
CharacterFrame([r' o ', r'\█-=> ', r' / \ ']),
CharacterFrame([r' o ', r' /█\ ', r' / \ ']),
],
hit: [
CharacterFrame([r' o ', r'/█\* ', r' / \ ']),
CharacterFrame([r' o ', r'/█\ * ', r' / \ ']),
CharacterFrame([r' o ', r' /█\ ', r' / \ ']),
],
recover: [
CharacterFrame([r' o ', r' /█\ ', r' | ']),
CharacterFrame([r' o ', r' /█\ ', r' / \ ']),
CharacterFrame([r' o ', r' /█\ ', r' / \ ']),
],
),
// --------------------------------------------------------------------------
// 대형 (Kernel Giant, Heap Troll)
// --------------------------------------------------------------------------
'kernel_giant': RaceFrameData(
raceId: 'kernel_giant',
idle: [
CharacterFrame([r' O ', r' /█\ ', r' ┘ └ ']),
CharacterFrame([r' O ', r' /█\ ', r' | | ']),
CharacterFrame([r' O ', r' /█\ ', r' ┘ └ ']),
CharacterFrame([r' O ', r' /█\ ', r' ┘ └ ']),
],
prepare: [
CharacterFrame([r' O ', r' \█\ ', r' ┘ └ ']),
CharacterFrame([r' O ', r' \█ ', r' ┘ └ ']),
CharacterFrame([r' O ', r' \█/ ', r' ┘ └ ']),
],
attack: [
CharacterFrame([r' O ', r' \█ ', r' ┘ └ ']),
CharacterFrame([r' O ', r' \█- ', r' ┘ └ ']),
CharacterFrame([r' O ', r' \█-- ', r' ┘ └ ']),
CharacterFrame([r' O ', r'\█-=> ', r' ┘ └ ']),
CharacterFrame([r' O ', r' /█\ ', r' ┘ └ ']),
],
hit: [
CharacterFrame([r' O ', r'/█\* ', r' ┘ └ ']),
CharacterFrame([r' O ', r'/█\ * ', r' ┘ └ ']),
CharacterFrame([r' O ', r' /█\ ', r' ┘ └ ']),
],
recover: [
CharacterFrame([r' O ', r' /█\ ', r' || ']),
CharacterFrame([r' O ', r' /█\ ', r' ┘ └ ']),
CharacterFrame([r' O ', r' /█\ ', r' ┘ └ ']),
],
),
'heap_troll': RaceFrameData(
raceId: 'heap_troll',
idle: [
CharacterFrame([r' Ö ', r' /▓\ ', r' ┘ └ ']),
CharacterFrame([r' Ö ', r' /▓\ ', r' | | ']),
CharacterFrame([r' Ö ', r' /▓\ ', r' ┘ └ ']),
CharacterFrame([r' Ö ', r' /▓\ ', r' ┘ └ ']),
],
prepare: [
CharacterFrame([r' Ö ', r' \▓\ ', r' ┘ └ ']),
CharacterFrame([r' Ö ', r' \▓ ', r' ┘ └ ']),
CharacterFrame([r' Ö ', r' \▓/ ', r' ┘ └ ']),
],
attack: [
CharacterFrame([r' Ö ', r' \▓ ', r' ┘ └ ']),
CharacterFrame([r' Ö ', r' \▓- ', r' ┘ └ ']),
CharacterFrame([r' Ö ', r' \▓-- ', r' ┘ └ ']),
CharacterFrame([r' Ö ', r'\▓-=> ', r' ┘ └ ']),
CharacterFrame([r' Ö ', r' /▓\ ', r' ┘ └ ']),
],
hit: [
CharacterFrame([r' Ö ', r'/▓\* ', r' ┘ └ ']),
CharacterFrame([r' Ö ', r'/▓\ * ', r' ┘ └ ']),
CharacterFrame([r' Ö ', r' /▓\ ', r' ┘ └ ']),
],
recover: [
CharacterFrame([r' Ö ', r' /▓\ ', r' || ']),
CharacterFrame([r' Ö ', r' /▓\ ', r' ┘ └ ']),
CharacterFrame([r' Ö ', r' /▓\ ', r' ┘ └ ']),
],
),
// --------------------------------------------------------------------------
// 전사형 (Protocol Valkyrie, Flag Golem, Array Orc)
// --------------------------------------------------------------------------
'protocol_valkyrie': RaceFrameData(
raceId: 'protocol_valkyrie',
idle: [
CharacterFrame([r' o ', r' /|+ ', r' / \ ']),
CharacterFrame([r' o ', r' /|+ ', r' | | ']),
CharacterFrame([r' o ', r' /|+ ', r' / \ ']),
CharacterFrame([r' O ', r' /|+ ', r' / \ ']),
],
prepare: [
CharacterFrame([r' o ', r' \|+ ', r' / \ ']),
CharacterFrame([r' o ', r' \| ', r' / \ ']),
CharacterFrame([r' o ', r' \|/ ', r' / \ ']),
],
attack: [
CharacterFrame([r' o ', r' \| ', r' / \ ']),
CharacterFrame([r' o ', r' \|- ', r' / \ ']),
CharacterFrame([r' o ', r' \|-- ', r' / \ ']),
CharacterFrame([r' o ', r'\|-+> ', r' / \ ']),
CharacterFrame([r' o ', r' /|+ ', r' / \ ']),
],
hit: [
CharacterFrame([r' o ', r'/|+* ', r' / \ ']),
CharacterFrame([r' o ', r'/|+ * ', r' / \ ']),
CharacterFrame([r' o ', r' /|+ ', r' / \ ']),
],
recover: [
CharacterFrame([r' o ', r' /|+ ', r' | ']),
CharacterFrame([r' o ', r' /|+ ', r' / \ ']),
CharacterFrame([r' o ', r' /|+ ', r' / \ ']),
],
),
'flag_golem': RaceFrameData(
raceId: 'flag_golem',
idle: [
CharacterFrame([r' ♦ ', r' /|] ', r' / \ ']),
CharacterFrame([r' ♦ ', r' /|] ', r' | | ']),
CharacterFrame([r' ♦ ', r' /|] ', r' / \ ']),
CharacterFrame([r' ◆ ', r' /|] ', r' / \ ']),
],
prepare: [
CharacterFrame([r' ♦ ', r' \|] ', r' / \ ']),
CharacterFrame([r' ♦ ', r' \| ', r' / \ ']),
CharacterFrame([r' ♦ ', r' \|/ ', r' / \ ']),
],
attack: [
CharacterFrame([r' ♦ ', r' \| ', r' / \ ']),
CharacterFrame([r' ♦ ', r' \|- ', r' / \ ']),
CharacterFrame([r' ♦ ', r' \|-- ', r' / \ ']),
CharacterFrame([r' ♦ ', r'\|--> ', r' / \ ']),
CharacterFrame([r' ♦ ', r' /|] ', r' / \ ']),
],
hit: [
CharacterFrame([r' ♦ ', r'/|]* ', r' / \ ']),
CharacterFrame([r' ♦ ', r'/|] * ', r' / \ ']),
CharacterFrame([r' ♦ ', r' /|] ', r' / \ ']),
],
recover: [
CharacterFrame([r' ♦ ', r' /|] ', r' | ']),
CharacterFrame([r' ♦ ', r' /|] ', r' / \ ']),
CharacterFrame([r' ♦ ', r' /|] ', r' / \ ']),
],
),
'array_orc': RaceFrameData(
raceId: 'array_orc',
idle: [
CharacterFrame([r' ö ', r' /▒\ ', r' / \ ']),
CharacterFrame([r' ö ', r' /▒\ ', r' | | ']),
CharacterFrame([r' ö ', r' /▒\ ', r' / \ ']),
CharacterFrame([r' Ö ', r' /▒\ ', r' / \ ']),
],
prepare: [
CharacterFrame([r' ö ', r' \▒\ ', r' / \ ']),
CharacterFrame([r' ö ', r' \▒ ', r' / \ ']),
CharacterFrame([r' ö ', r' \▒/ ', r' / \ ']),
],
attack: [
CharacterFrame([r' ö ', r' \▒ ', r' / \ ']),
CharacterFrame([r' ö ', r' \▒- ', r' / \ ']),
CharacterFrame([r' ö ', r' \▒-- ', r' / \ ']),
CharacterFrame([r' ö ', r'\▒-=> ', r' / \ ']),
CharacterFrame([r' ö ', r' /▒\ ', r' / \ ']),
],
hit: [
CharacterFrame([r' ö ', r'/▒\* ', r' / \ ']),
CharacterFrame([r' ö ', r'/▒\ * ', r' / \ ']),
CharacterFrame([r' ö ', r' /▒\ ', r' / \ ']),
],
recover: [
CharacterFrame([r' ö ', r' /▒\ ', r' | ']),
CharacterFrame([r' ö ', r' /▒\ ', r' / \ ']),
CharacterFrame([r' ö ', r' /▒\ ', r' / \ ']),
],
),
// --------------------------------------------------------------------------
// 마법형 (Loop Djinn, Lambda Dryad, Recursive Sylvan)
// --------------------------------------------------------------------------
'loop_djinn': RaceFrameData(
raceId: 'loop_djinn',
idle: [
CharacterFrame([r' o~ ', r' /|) ', r' / \ ']),
CharacterFrame([r' o~ ', r' /|) ', r' | | ']),
CharacterFrame([r' o~ ', r' /|) ', r' / \ ']),
CharacterFrame([r' O~ ', r' /|) ', r' / \ ']),
],
prepare: [
CharacterFrame([r' o~ ', r' \|) ', r' / \ ']),
CharacterFrame([r' o~ ', r' \| ', r' / \ ']),
CharacterFrame([r' o~ ', r' \|/ ', r' / \ ']),
],
attack: [
CharacterFrame([r' o~ ', r' \| ', r' / \ ']),
CharacterFrame([r' o~ ', r' \|~ ', r' / \ ']),
CharacterFrame([r' o~ ', r' \|~~ ', r' / \ ']),
CharacterFrame([r' o~ ', r'\|~~* ', r' / \ ']),
CharacterFrame([r' o~ ', r' /|) ', r' / \ ']),
],
hit: [
CharacterFrame([r' o~ ', r'/|)* ', r' / \ ']),
CharacterFrame([r' o~ ', r'/|) * ', r' / \ ']),
CharacterFrame([r' o~ ', r' /|) ', r' / \ ']),
],
recover: [
CharacterFrame([r' o~ ', r' /|) ', r' | ']),
CharacterFrame([r' o~ ', r' /|) ', r' / \ ']),
CharacterFrame([r' o~ ', r' /|) ', r' / \ ']),
],
),
'lambda_dryad': RaceFrameData(
raceId: 'lambda_dryad',
idle: [
CharacterFrame([r' o♣ ', r' /|) ', r' / \ ']),
CharacterFrame([r' o♣ ', r' /|) ', r' | | ']),
CharacterFrame([r' o♣ ', r' /|) ', r' / \ ']),
CharacterFrame([r' O♣ ', r' /|) ', r' / \ ']),
],
prepare: [
CharacterFrame([r' o♣ ', r' \|) ', r' / \ ']),
CharacterFrame([r' o♣ ', r' \| ', r' / \ ']),
CharacterFrame([r' o♣ ', r' \|/ ', r' / \ ']),
],
attack: [
CharacterFrame([r' o♣ ', r' \| ', r' / \ ']),
CharacterFrame([r' o♣ ', r' \|~ ', r' / \ ']),
CharacterFrame([r' o♣ ', r' \|~~ ', r' / \ ']),
CharacterFrame([r' o♣ ', r'\|~~♣ ', r' / \ ']),
CharacterFrame([r' o♣ ', r' /|) ', r' / \ ']),
],
hit: [
CharacterFrame([r' o♣ ', r'/|)* ', r' / \ ']),
CharacterFrame([r' o♣ ', r'/|) * ', r' / \ ']),
CharacterFrame([r' o♣ ', r' /|) ', r' / \ ']),
],
recover: [
CharacterFrame([r' o♣ ', r' /|) ', r' | ']),
CharacterFrame([r' o♣ ', r' /|) ', r' / \ ']),
CharacterFrame([r' o♣ ', r' /|) ', r' / \ ']),
],
),
'recursive_sylvan': RaceFrameData(
raceId: 'recursive_sylvan',
idle: [
CharacterFrame([r' o∞ ', r' /|) ', r' / \ ']),
CharacterFrame([r' o∞ ', r' /|) ', r' | | ']),
CharacterFrame([r' o∞ ', r' /|) ', r' / \ ']),
CharacterFrame([r' O∞ ', r' /|) ', r' / \ ']),
],
prepare: [
CharacterFrame([r' o∞ ', r' \|) ', r' / \ ']),
CharacterFrame([r' o∞ ', r' \| ', r' / \ ']),
CharacterFrame([r' o∞ ', r' \|/ ', r' / \ ']),
],
attack: [
CharacterFrame([r' o∞ ', r' \| ', r' / \ ']),
CharacterFrame([r' o∞ ', r' \|~ ', r' / \ ']),
CharacterFrame([r' o∞ ', r' \|~~ ', r' / \ ']),
CharacterFrame([r' o∞ ', r'\|~~∞ ', r' / \ ']),
CharacterFrame([r' o∞ ', r' /|) ', r' / \ ']),
],
hit: [
CharacterFrame([r' o∞ ', r'/|)* ', r' / \ ']),
CharacterFrame([r' o∞ ', r'/|) * ', r' / \ ']),
CharacterFrame([r' o∞ ', r' /|) ', r' / \ ']),
],
recover: [
CharacterFrame([r' o∞ ', r' /|) ', r' | ']),
CharacterFrame([r' o∞ ', r' /|) ', r' / \ ']),
CharacterFrame([r' o∞ ', r' /|) ', r' / \ ']),
],
),
// --------------------------------------------------------------------------
// 언데드/엘프 (Coredump Undead, Null Elf, Callback Seraph)
// --------------------------------------------------------------------------
'coredump_undead': RaceFrameData(
raceId: 'coredump_undead',
idle: [
CharacterFrame([r' ☠ ', r' /|\ ', r' / \ ']),
CharacterFrame([r' ☠ ', r' /|\ ', r' | | ']),
CharacterFrame([r' ☠ ', r' /|\ ', r' / \ ']),
CharacterFrame([r' ☠ ', r' /|\ ', r' / \ ']),
],
prepare: [
CharacterFrame([r' ☠ ', r' \|\ ', r' / \ ']),
CharacterFrame([r' ☠ ', r' \| ', r' / \ ']),
CharacterFrame([r' ☠ ', r' \|/ ', r' / \ ']),
],
attack: [
CharacterFrame([r' ☠ ', r' \| ', r' / \ ']),
CharacterFrame([r' ☠ ', r' \|- ', r' / \ ']),
CharacterFrame([r' ☠ ', r' \|-- ', r' / \ ']),
CharacterFrame([r' ☠ ', r'\|-~> ', r' / \ ']),
CharacterFrame([r' ☠ ', r' /|\ ', r' / \ ']),
],
hit: [
CharacterFrame([r' ☠ ', r'/|\~ ', r' / \ ']),
CharacterFrame([r' ☠ ', r'/|\ ~ ', r' / \ ']),
CharacterFrame([r' ☠ ', r' /|\ ', r' / \ ']),
],
recover: [
CharacterFrame([r' ☠ ', r' /|\ ', r' | ']),
CharacterFrame([r' ☠ ', r' /|\ ', r' / \ ']),
CharacterFrame([r' ☠ ', r' /|\ ', r' / \ ']),
],
),
'null_elf': RaceFrameData(
raceId: 'null_elf',
idle: [
CharacterFrame([r' ö ', r' /|\ ', r' / \ ']),
CharacterFrame([r' ö ', r' /|\ ', r' | | ']),
CharacterFrame([r' ö ', r' /|\ ', r' / \ ']),
CharacterFrame([r' Ö ', r' /|\ ', r' / \ ']),
],
prepare: [
CharacterFrame([r' ö ', r' \|\ ', r' / \ ']),
CharacterFrame([r' ö ', r' \| ', r' / \ ']),
CharacterFrame([r' ö ', r' \|/ ', r' / \ ']),
],
attack: [
CharacterFrame([r' ö ', r' \| ', r' / \ ']),
CharacterFrame([r' ö ', r' \|~ ', r' / \ ']),
CharacterFrame([r' ö ', r' \|~~ ', r' / \ ']),
CharacterFrame([r' ö ', r'\|~~* ', r' / \ ']),
CharacterFrame([r' ö ', r' /|\ ', r' / \ ']),
],
hit: [
CharacterFrame([r' ö ', r'/|\* ', r' / \ ']),
CharacterFrame([r' ö ', r'/|\ * ', r' / \ ']),
CharacterFrame([r' ö ', r' /|\ ', r' / \ ']),
],
recover: [
CharacterFrame([r' ö ', r' /|\ ', r' | ']),
CharacterFrame([r' ö ', r' /|\ ', r' / \ ']),
CharacterFrame([r' ö ', r' /|\ ', r' / \ ']),
],
),
'callback_seraph': RaceFrameData(
raceId: 'callback_seraph',
idle: [
CharacterFrame([r' ô ', r' /|\ ', r' / \ ']),
CharacterFrame([r' ô ', r' /|\ ', r' | | ']),
CharacterFrame([r' ô ', r' /|\ ', r' / \ ']),
CharacterFrame([r' Ô ', r' /|\ ', r' / \ ']),
],
prepare: [
CharacterFrame([r' ô ', r' \|\ ', r' / \ ']),
CharacterFrame([r' ô ', r' \| ', r' / \ ']),
CharacterFrame([r' ô ', r' \|/ ', r' / \ ']),
],
attack: [
CharacterFrame([r' ô ', r' \| ', r' / \ ']),
CharacterFrame([r' ô ', r' \|+ ', r' / \ ']),
CharacterFrame([r' ô ', r' \|++ ', r' / \ ']),
CharacterFrame([r' ô ', r'\|++* ', r' / \ ']),
CharacterFrame([r' ô ', r' /|\ ', r' / \ ']),
],
hit: [
CharacterFrame([r' ô ', r'/|\+ ', r' / \ ']),
CharacterFrame([r' ô ', r'/|\ + ', r' / \ ']),
CharacterFrame([r' ô ', r' /|\ ', r' / \ ']),
],
recover: [
CharacterFrame([r' ô ', r' /|\ ', r' | ']),
CharacterFrame([r' ô ', r' /|\ ', r' / \ ']),
CharacterFrame([r' ô ', r' /|\ ', r' / \ ']),
],
),
// --------------------------------------------------------------------------
// 소형 (Bit Halfling, Stack Goblin, Cache Imp)
// --------------------------------------------------------------------------
'bit_halfling': RaceFrameData(
raceId: 'bit_halfling',
idle: [
CharacterFrame([r' o ', r' /|\ ', r' Y ']),
CharacterFrame([r' o ', r' /|\ ', r' | ']),
CharacterFrame([r' o ', r' /|\ ', r' Y ']),
CharacterFrame([r' O ', r' /|\ ', r' Y ']),
],
prepare: [
CharacterFrame([r' o ', r' \|\ ', r' Y ']),
CharacterFrame([r' o ', r' \| ', r' Y ']),
CharacterFrame([r' o ', r' \|/ ', r' Y ']),
],
attack: [
CharacterFrame([r' o ', r' \| ', r' Y ']),
CharacterFrame([r' o ', r' \|- ', r' Y ']),
CharacterFrame([r' o ', r' \|-- ', r' Y ']),
CharacterFrame([r' o ', r'\|-=> ', r' Y ']),
CharacterFrame([r' o ', r' /|\ ', r' Y ']),
],
hit: [
CharacterFrame([r' o ', r'/|\* ', r' Y ']),
CharacterFrame([r' o ', r'/|\ * ', r' Y ']),
CharacterFrame([r' o ', r' /|\ ', r' Y ']),
],
recover: [
CharacterFrame([r' o ', r' /|\ ', r' | ']),
CharacterFrame([r' o ', r' /|\ ', r' Y ']),
CharacterFrame([r' o ', r' /|\ ', r' Y ']),
],
),
'stack_goblin': RaceFrameData(
raceId: 'stack_goblin',
idle: [
CharacterFrame([r' ◦ ', r' /|\ ', r' Y ']),
CharacterFrame([r' ◦ ', r' /|\ ', r' | ']),
CharacterFrame([r' ◦ ', r' /|\ ', r' Y ']),
CharacterFrame([r' ● ', r' /|\ ', r' Y ']),
],
prepare: [
CharacterFrame([r' ◦ ', r' \|\ ', r' Y ']),
CharacterFrame([r' ◦ ', r' \| ', r' Y ']),
CharacterFrame([r' ◦ ', r' \|/ ', r' Y ']),
],
attack: [
CharacterFrame([r' ◦ ', r' \| ', r' Y ']),
CharacterFrame([r' ◦ ', r' \|- ', r' Y ']),
CharacterFrame([r' ◦ ', r' \|-- ', r' Y ']),
CharacterFrame([r' ◦ ', r'\|-=> ', r' Y ']),
CharacterFrame([r' ◦ ', r' /|\ ', r' Y ']),
],
hit: [
CharacterFrame([r' ◦ ', r'/|\* ', r' Y ']),
CharacterFrame([r' ◦ ', r'/|\ * ', r' Y ']),
CharacterFrame([r' ◦ ', r' /|\ ', r' Y ']),
],
recover: [
CharacterFrame([r' ◦ ', r' /|\ ', r' | ']),
CharacterFrame([r' ◦ ', r' /|\ ', r' Y ']),
CharacterFrame([r' ◦ ', r' /|\ ', r' Y ']),
],
),
'cache_imp': RaceFrameData(
raceId: 'cache_imp',
idle: [
CharacterFrame([r' ^ ', r' /|\ ', r' Y ']),
CharacterFrame([r' ^ ', r' /|\ ', r' | ']),
CharacterFrame([r' ^ ', r' /|\ ', r' Y ']),
CharacterFrame([r' ^ ', r' /|\ ', r' Y ']),
],
prepare: [
CharacterFrame([r' ^ ', r' \|\ ', r' Y ']),
CharacterFrame([r' ^ ', r' \| ', r' Y ']),
CharacterFrame([r' ^ ', r' \|/ ', r' Y ']),
],
attack: [
CharacterFrame([r' ^ ', r' \| ', r' Y ']),
CharacterFrame([r' ^ ', r' \|- ', r' Y ']),
CharacterFrame([r' ^ ', r' \|-- ', r' Y ']),
CharacterFrame([r' ^ ', r'\|--> ', r' Y ']),
CharacterFrame([r' ^ ', r' /|\ ', r' Y ']),
],
hit: [
CharacterFrame([r' ^ ', r'/|\* ', r' Y ']),
CharacterFrame([r' ^ ', r'/|\ * ', r' Y ']),
CharacterFrame([r' ^ ', r' /|\ ', r' Y ']),
],
recover: [
CharacterFrame([r' ^ ', r' /|\ ', r' | ']),
CharacterFrame([r' ^ ', r' /|\ ', r' Y ']),
CharacterFrame([r' ^ ', r' /|\ ', r' Y ']),
],
),
// --------------------------------------------------------------------------
// 영체 (Thread Spirit, Pointer Fairy)
// --------------------------------------------------------------------------
'thread_spirit': RaceFrameData(
raceId: 'thread_spirit',
idle: [
CharacterFrame([r' ·o· ', r' | ', r' ~~~ ']),
CharacterFrame([r' ·o· ', r' | ', r' ~~ ']),
CharacterFrame([r' ·o· ', r' | ', r' ~~~ ']),
CharacterFrame([r' ·O· ', r' | ', r' ~~~ ']),
],
prepare: [
CharacterFrame([r' ·o· ', r' \| ', r' ~~~ ']),
CharacterFrame([r' ·o· ', r' \ ', r' ~~~ ']),
CharacterFrame([r' ·o· ', r' \/ ', r' ~~~ ']),
],
attack: [
CharacterFrame([r' ·o· ', r' \ ', r' ~~~ ']),
CharacterFrame([r' ·o· ', r' \~ ', r' ~~~ ']),
CharacterFrame([r' ·o· ', r' \~~ ', r' ~~~ ']),
CharacterFrame([r' ·o· ', r'\~~* ', r' ~~~ ']),
CharacterFrame([r' ·o· ', r' | ', r' ~~~ ']),
],
hit: [
CharacterFrame([r' ·o· ', r' |* ', r' ~~~ ']),
CharacterFrame([r' ·o· ', r' | * ', r' ~~~ ']),
CharacterFrame([r' ·o· ', r' | ', r' ~~~ ']),
],
recover: [
CharacterFrame([r' ·o· ', r' | ', r' ~~ ']),
CharacterFrame([r' ·o· ', r' | ', r' ~~~ ']),
CharacterFrame([r' ·o· ', r' | ', r' ~~~ ']),
],
),
'pointer_fairy': RaceFrameData(
raceId: 'pointer_fairy',
idle: [
CharacterFrame([r' *o* ', r' | ', r' ~~~ ']),
CharacterFrame([r' *o* ', r' | ', r' ~~ ']),
CharacterFrame([r' *o* ', r' | ', r' ~~~ ']),
CharacterFrame([r' *O* ', r' | ', r' ~~~ ']),
],
prepare: [
CharacterFrame([r' *o* ', r' \| ', r' ~~~ ']),
CharacterFrame([r' *o* ', r' \ ', r' ~~~ ']),
CharacterFrame([r' *o* ', r' \/ ', r' ~~~ ']),
],
attack: [
CharacterFrame([r' *o* ', r' \ ', r' ~~~ ']),
CharacterFrame([r' *o* ', r' \* ', r' ~~~ ']),
CharacterFrame([r' *o* ', r' \** ', r' ~~~ ']),
CharacterFrame([r' *o* ', r'\**+ ', r' ~~~ ']),
CharacterFrame([r' *o* ', r' | ', r' ~~~ ']),
],
hit: [
CharacterFrame([r' *o* ', r' |+ ', r' ~~~ ']),
CharacterFrame([r' *o* ', r' | + ', r' ~~~ ']),
CharacterFrame([r' *o* ', r' | ', r' ~~~ ']),
],
recover: [
CharacterFrame([r' *o* ', r' | ', r' ~~ ']),
CharacterFrame([r' *o* ', r' | ', r' ~~~ ']),
CharacterFrame([r' *o* ', r' | ', r' ~~~ ']),
],
),
// --------------------------------------------------------------------------
// 민첩형 (Index Feline, Iterator Shade)
// --------------------------------------------------------------------------
'index_feline': RaceFrameData(
raceId: 'index_feline',
idle: [
CharacterFrame([r' o ', r' /|\ ', r' λ \ ']),
CharacterFrame([r' o ', r' /|\ ', r' | | ']),
CharacterFrame([r' o ', r' /|\ ', r' λ \ ']),
CharacterFrame([r' O ', r' /|\ ', r' λ \ ']),
],
prepare: [
CharacterFrame([r' o ', r' \|\ ', r' λ \ ']),
CharacterFrame([r' o ', r' \| ', r' λ \ ']),
CharacterFrame([r' o ', r' \|/ ', r' λ \ ']),
],
attack: [
CharacterFrame([r' o ', r' \| ', r' λ \ ']),
CharacterFrame([r' o ', r' \|) ', r' λ \ ']),
CharacterFrame([r' o ', r' \|)) ', r' λ \ ']),
CharacterFrame([r' o ', r'\|))> ', r' λ \ ']),
CharacterFrame([r' o ', r' /|\ ', r' λ \ ']),
],
hit: [
CharacterFrame([r' o ', r'/|\* ', r' λ \ ']),
CharacterFrame([r' o ', r'/|\ * ', r' λ \ ']),
CharacterFrame([r' o ', r' /|\ ', r' λ \ ']),
],
recover: [
CharacterFrame([r' o ', r' /|\ ', r' | ']),
CharacterFrame([r' o ', r' /|\ ', r' λ \ ']),
CharacterFrame([r' o ', r' /|\ ', r' λ \ ']),
],
),
'iterator_shade': RaceFrameData(
raceId: 'iterator_shade',
idle: [
CharacterFrame([r' o ', r' /|\ ', r' λ \ ']),
CharacterFrame([r' o ', r' /|\ ', r' | | ']),
CharacterFrame([r' o ', r' /|\ ', r' λ \ ']),
CharacterFrame([r' o ', r' /|\ ', r' λ \ ']),
],
prepare: [
CharacterFrame([r' o ', r' \|\ ', r' λ \ ']),
CharacterFrame([r' o ', r' \| ', r' λ \ ']),
CharacterFrame([r' o ', r' \|/ ', r' λ \ ']),
],
attack: [
CharacterFrame([r' o ', r' \| ', r' λ \ ']),
CharacterFrame([r' o ', r' \|- ', r' λ \ ']),
CharacterFrame([r' o ', r' \|-- ', r' λ \ ']),
CharacterFrame([r' o ', r'\|--x ', r' λ \ ']),
CharacterFrame([r' o ', r' /|\ ', r' λ \ ']),
],
hit: [
CharacterFrame([r' o ', r'/|\x ', r' λ \ ']),
CharacterFrame([r' o ', r'/|\ x ', r' λ \ ']),
CharacterFrame([r' o ', r' /|\ ', r' λ \ ']),
],
recover: [
CharacterFrame([r' o ', r' /|\ ', r' | ']),
CharacterFrame([r' o ', r' /|\ ', r' λ \ ']),
CharacterFrame([r' o ', r' /|\ ', r' λ \ ']),
],
),
// --------------------------------------------------------------------------
// 노움 (Register Gnome)
// --------------------------------------------------------------------------
'register_gnome': RaceFrameData(
raceId: 'register_gnome',
idle: [
CharacterFrame([r' ô ', r' /|\ ', r' ∧ ']),
CharacterFrame([r' ô ', r' /|\ ', r' | ']),
CharacterFrame([r' ô ', r' /|\ ', r' ∧ ']),
CharacterFrame([r' Ô ', r' /|\ ', r' ∧ ']),
],
prepare: [
CharacterFrame([r' ô ', r' \|\ ', r' ∧ ']),
CharacterFrame([r' ô ', r' \| ', r' ∧ ']),
CharacterFrame([r' ô ', r' \|/ ', r' ∧ ']),
],
attack: [
CharacterFrame([r' ô ', r' \| ', r' ∧ ']),
CharacterFrame([r' ô ', r' \|* ', r' ∧ ']),
CharacterFrame([r' ô ', r' \|** ', r' ∧ ']),
CharacterFrame([r' ô ', r'\|**> ', r' ∧ ']),
CharacterFrame([r' ô ', r' /|\ ', r' ∧ ']),
],
hit: [
CharacterFrame([r' ô ', r'/|\* ', r' ∧ ']),
CharacterFrame([r' ô ', r'/|\ * ', r' ∧ ']),
CharacterFrame([r' ô ', r' /|\ ', r' ∧ ']),
],
recover: [
CharacterFrame([r' ô ', r' /|\ ', r' | ']),
CharacterFrame([r' ô ', r' /|\ ', r' ∧ ']),
CharacterFrame([r' ô ', r' /|\ ', r' ∧ ']),
],
),
};

View File

@@ -1,104 +0,0 @@
/// 무기 카테고리 (공격 스타일 결정용)
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

@@ -1,190 +0,0 @@
import 'package:asciineverdie/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,
};
}
// ============================================================================
// 둔기류 - 휘두르기 (5줄)
// ============================================================================
const _bluntEffect = 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' ', r' _/____=> ', r'/ ', r' ', r' '],
],
hitFrames: [
[r' *BASH* ', r' _/____=> ', r'/ ** ', r' ** ', r' '],
[r' *SMASH!* ', r' _/____ ', r' / ** ', r' ** ', r' '],
],
hitSound: '*BASH*',
effectHeight: 5,
effectYStart: 1,
);
// ============================================================================
// 케이블류 - 채찍질 (5줄)
// ============================================================================
const _cableEffect = 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' ', r'~~~~~~> ', r' ~~~~', r' ', r' '],
],
hitFrames: [
[r' *WHIP* ', r'~~~~~~> ', r' ~~~~', r' **', r' '],
[r' *CRACK!* ', r'~~~~~> ', r' ~~~~~', r' **', r' '],
],
hitSound: '*WHIP*',
effectHeight: 5,
effectYStart: 1,
);
// ============================================================================
// 투척류 - 발사 (5줄)
// ============================================================================
const _projectileEffect = 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' ',
r' ',
r' [>',
r' ',
r' ',
],
],
hitFrames: [
[r' *CLANG!* ', r' ***', r' [>', r' ***', r' '],
[r' *CRASH!* ', r' *** ', r' [> ', r' *** ', r' '],
],
hitSound: '*CLANG*',
effectHeight: 5,
effectYStart: 1,
);
// ============================================================================
// 에너지류 - 빔 발사 (5줄)
// ============================================================================
const _energyEffect = 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'==========', r'==========', r'=====<**>=', r'==========', r'=========='],
],
hitFrames: [
[r'===*ZAP*==', r'==========', r'====<**>==', r'==========', r'===*ZAP*=='],
[r'==*BZZT!*=', r'==========', r'=====<**>=', r'==========', r'==*BZZT!*='],
],
hitSound: '*ZAP*',
effectHeight: 5,
effectYStart: 1,
);
// ============================================================================
// 우주급 - 초월적 공격 (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,
);
// ============================================================================
// 맨손 - 기본 펀치 (5줄)
// ============================================================================
const _unarmedEffect = 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' '],
],
hitFrames: [
[r' *POW!* ', r' ****', r' ======>', r' ****', r' '],
[r' *PUNCH!* ', r' **** ', r' =====> ', r' **** ', r' '],
],
hitSound: '*POW*',
effectHeight: 5,
effectYStart: 1,
);

View File

@@ -1,171 +0,0 @@
import 'package:flutter/material.dart';
import 'package:asciineverdie/src/shared/retro_colors.dart';
/// ASCII 애니메이션 4색 팔레트 (Phase 7)
///
/// 시각적 명확성을 위해 4가지 색상만 사용한다.
/// - 흰색/검정: 오브젝트 (캐릭터, 몬스터, 아이템) - 테마에 따라 변환
/// - 시안/파랑: 포지티브 이펙트 (힐, 버프, 레벨업, 획득) - 테마에 따라 변환
/// - 마젠타/빨강: 네거티브 이펙트 (데미지, 디버프, 사망, 손실) - 테마에 따라 변환
/// - 검정/밝은색: 배경 - 테마에 따라 변환
class AsciiColors {
AsciiColors._();
// ═══════════════════════════════════════════════════════════════════════
// 동적 색상 Getter (테마에 따라 자동 전환)
// ═══════════════════════════════════════════════════════════════════════
/// 오브젝트 색상 (캐릭터, 몬스터, 아이템) - 테마 인식
static Color objectOf(BuildContext context) =>
RetroColors.isDarkMode(context) ? object : _lightObject;
/// 포지티브 이펙트 색상 - 테마 인식
static Color positiveOf(BuildContext context) =>
RetroColors.isDarkMode(context) ? positive : _lightPositive;
/// 네거티브 이펙트 색상 - 테마 인식
static Color negativeOf(BuildContext context) =>
RetroColors.isDarkMode(context) ? negative : _lightNegative;
/// 배경 색상 - 테마 인식
static Color backgroundOf(BuildContext context) =>
RetroColors.isDarkMode(context) ? background : _lightBackground;
// ═══════════════════════════════════════════════════════════════════════
// 무기 등급(ItemRarity) 색상 Getter (테마 인식, Phase 9)
// ═══════════════════════════════════════════════════════════════════════
/// Uncommon 등급 색상 - 테마 인식
static Color rarityUncommonOf(BuildContext context) =>
RetroColors.isDarkMode(context) ? rarityUncommon : _lightRarityUncommon;
/// Rare 등급 색상 - 테마 인식
static Color rarityRareOf(BuildContext context) =>
RetroColors.isDarkMode(context) ? rarityRare : _lightRarityRare;
/// Epic 등급 색상 - 테마 인식
static Color rarityEpicOf(BuildContext context) =>
RetroColors.isDarkMode(context) ? rarityEpic : _lightRarityEpic;
/// Legendary 등급 색상 - 테마 인식
static Color rarityLegendaryOf(BuildContext context) =>
RetroColors.isDarkMode(context) ? rarityLegendary : _lightRarityLegendary;
// ═══════════════════════════════════════════════════════════════════════
// 라이트 모드 색상 (양피지/크림 기반)
// ═══════════════════════════════════════════════════════════════════════
/// 라이트 모드 오브젝트 색상 (어두운 갈색)
static const Color _lightObject = Color(0xFF2D1B0E);
/// 라이트 모드 포지티브 이펙트 (진한 청록)
static const Color _lightPositive = Color(0xFF006666);
/// 라이트 모드 네거티브 이펙트 (진한 자주)
static const Color _lightNegative = Color(0xFFAA0066);
/// 라이트 모드 배경 (양피지 크림)
static const Color _lightBackground = Color(0xFFF5E6C8);
/// 라이트 모드 Uncommon 등급 (진한 초록)
static const Color _lightRarityUncommon = Color(0xFF008800);
/// 라이트 모드 Rare 등급 (진한 파랑)
static const Color _lightRarityRare = Color(0xFF0055AA);
/// 라이트 모드 Epic 등급 (진한 보라)
static const Color _lightRarityEpic = Color(0xFF660099);
/// 라이트 모드 Legendary 등급 (진한 금색)
static const Color _lightRarityLegendary = Color(0xFFCC7700);
// ═══════════════════════════════════════════════════════════════════════
// 레거시 정적 색상 (다크 모드 기본값 / context 없는 곳에서 사용)
// ═══════════════════════════════════════════════════════════════════════
/// 오브젝트 색상 (캐릭터, 몬스터, 아이템)
static const Color object = Colors.white;
/// 포지티브 이펙트 색상 (힐, 버프, 레벨업, 획득)
static const Color positive = Colors.cyan;
/// 네거티브 이펙트 색상 (데미지, 디버프, 사망, 손실)
static const Color negative = Color(0xFFFF00FF); // 마젠타
/// 배경 색상
static const Color background = Colors.black;
// ═══════════════════════════════════════════════════════════════════════
// 무기 등급(ItemRarity) 정적 색상 (다크 모드 기본값, Phase 9)
// ═══════════════════════════════════════════════════════════════════════
/// Uncommon 등급 (밝은 초록)
static const Color rarityUncommon = Color(0xFF00FF00);
/// Rare 등급 (밝은 파랑)
static const Color rarityRare = Color(0xFF0088FF);
/// Epic 등급 (밝은 보라)
static const Color rarityEpic = Color(0xFF9900FF);
/// Legendary 등급 (밝은 금색)
static const Color rarityLegendary = Color(0xFFFFAA00);
/// 상황에 따른 색상 반환
static Color forContext(AsciiColorContext context) {
return switch (context) {
AsciiColorContext.idle => object,
AsciiColorContext.attack => object,
AsciiColorContext.critical => negative,
AsciiColorContext.heal => positive,
AsciiColorContext.buff => positive,
AsciiColorContext.debuff => negative,
AsciiColorContext.levelUp => positive,
AsciiColorContext.death => negative,
AsciiColorContext.itemGain => positive,
AsciiColorContext.itemLoss => negative,
AsciiColorContext.dodge => object,
AsciiColorContext.block => object,
};
}
}
/// ASCII 애니메이션 색상 컨텍스트
enum AsciiColorContext {
/// 대기 상태
idle,
/// 일반 공격
attack,
/// 크리티컬 히트
critical,
/// 회복
heal,
/// 버프 획득
buff,
/// 디버프 적용
debuff,
/// 레벨업
levelUp,
/// 사망
death,
/// 아이템 획득
itemGain,
/// 아이템 손실
itemLoss,
/// 회피 성공
dodge,
/// 방패 방어
block,
}

View File

@@ -1,545 +0,0 @@
import 'package:asciineverdie/data/game_text_l10n.dart' as l10n;
import 'package:asciineverdie/data/game_translations_ja.dart';
import 'package:asciineverdie/data/game_translations_ko.dart';
import 'package:asciineverdie/src/core/util/pq_logic.dart';
import 'package:flutter/widgets.dart';
/// 게임 데이터 번역을 위한 헬퍼 클래스
/// 현재 로케일에 따라 게임 데이터의 번역을 제공합니다.
///
/// 글로벌 로케일 시스템(game_text_l10n.dart)을 사용하여 일관성 보장
class GameDataL10n {
GameDataL10n._();
/// 현재 로케일이 한국어인지 확인 (글로벌 로케일 사용)
static bool _isKorean(BuildContext context) {
// 글로벌 로케일 우선, 폴백으로 context 로케일 사용
if (l10n.isKoreanLocale) return true;
final locale = Localizations.localeOf(context);
return locale.languageCode == 'ko';
}
/// 현재 로케일이 일본어인지 확인 (글로벌 로케일 사용)
static bool _isJapanese(BuildContext context) {
if (l10n.isJapaneseLocale) return true;
final locale = Localizations.localeOf(context);
return locale.languageCode == 'ja';
}
/// 종족 이름 번역
static String getRaceName(BuildContext context, String englishName) {
if (_isKorean(context)) {
return raceTranslationsKo[englishName] ?? englishName;
}
return englishName;
}
/// 직업 이름 번역
static String getKlassName(BuildContext context, String englishName) {
if (_isKorean(context)) {
return klassTranslationsKo[englishName] ?? englishName;
}
return englishName;
}
/// 주문 이름 번역
static String getSpellName(BuildContext context, String englishName) {
if (_isKorean(context)) {
return spellTranslationsKo[englishName] ?? englishName;
}
return englishName;
}
/// 몬스터 이름 번역
static String getMonsterName(BuildContext context, String englishName) {
if (_isKorean(context)) {
return monsterTranslationsKo[englishName] ?? englishName;
}
return englishName;
}
/// 무기 이름 번역
static String getWeaponName(BuildContext context, String englishName) {
if (_isKorean(context)) {
return weaponTranslationsKo[englishName] ?? englishName;
}
return englishName;
}
/// 갑옷 이름 번역
static String getArmorName(BuildContext context, String englishName) {
if (_isKorean(context)) {
return armorTranslationsKo[englishName] ?? englishName;
}
return englishName;
}
/// 방패 이름 번역
static String getShieldName(BuildContext context, String englishName) {
if (_isKorean(context)) {
return shieldTranslationsKo[englishName] ?? englishName;
}
return englishName;
}
/// 칭호 번역
static String getTitleName(BuildContext context, String englishName) {
if (_isKorean(context)) {
return titleTranslationsKo[englishName] ?? englishName;
}
return englishName;
}
/// 인상적인 칭호 번역
static String getImpressiveTitleName(
BuildContext context,
String englishName,
) {
if (_isKorean(context)) {
return impressiveTitleTranslationsKo[englishName] ?? englishName;
}
return englishName;
}
/// 아이템 속성 번역
static String getItemAttribName(BuildContext context, String englishName) {
if (_isKorean(context)) {
return itemAttribTranslationsKo[englishName] ?? englishName;
}
return englishName;
}
/// 아이템 접미사 번역
static String getItemOfName(BuildContext context, String englishName) {
if (_isKorean(context)) {
return itemOfsTranslationsKo[englishName] ?? englishName;
}
return englishName;
}
/// 공격 속성 번역
static String getOffenseAttribName(BuildContext context, String englishName) {
if (_isKorean(context)) {
return offenseAttribTranslationsKo[englishName] ?? englishName;
}
return englishName;
}
/// 나쁜 공격 속성 번역
static String getOffenseBadName(BuildContext context, String englishName) {
if (_isKorean(context)) {
return offenseBadTranslationsKo[englishName] ?? englishName;
}
return englishName;
}
/// 방어 속성 번역
static String getDefenseAttribName(BuildContext context, String englishName) {
if (_isKorean(context)) {
return defenseAttribTranslationsKo[englishName] ?? englishName;
}
return englishName;
}
/// 나쁜 방어 속성 번역
static String getDefenseBadName(BuildContext context, String englishName) {
if (_isKorean(context)) {
return defenseBadTranslationsKo[englishName] ?? englishName;
}
return englishName;
}
/// 특수 아이템 번역
static String getSpecialName(BuildContext context, String englishName) {
if (_isKorean(context)) {
return specialTranslationsKo[englishName] ?? englishName;
}
return englishName;
}
/// 일반 게임 텍스트 번역 (context 없이 로케일 직접 지정)
static String getRaceNameByLocale(String englishName, String languageCode) {
if (languageCode == 'ko') {
return raceTranslationsKo[englishName] ?? englishName;
}
return englishName;
}
/// 직업 이름 번역 (context 없이)
static String getKlassNameByLocale(String englishName, String languageCode) {
if (languageCode == 'ko') {
return klassTranslationsKo[englishName] ?? englishName;
}
return englishName;
}
/// 몬스터 이름 번역 (context 없이)
static String getMonsterNameByLocale(
String englishName,
String languageCode,
) {
if (languageCode == 'ko') {
return monsterTranslationsKo[englishName] ?? englishName;
}
return englishName;
}
/// 구조화된 장비 결과를 로컬라이즈된 문자열로 렌더링
/// 예: EquipResult(baseName: "Keyboard", modifiers: ["Optimized"], plusValue: 2)
/// → 영어: "+2 Optimized Keyboard"
/// → 한국어: "+2 최적화된 키보드"
static String renderEquipResult(
BuildContext context,
EquipResult result,
int slotIndex,
) {
if (_isKorean(context)) {
return _renderEquipResultKo(result, slotIndex);
}
return result.displayName;
}
/// 한국어 장비 렌더링 (내부 함수)
static String _renderEquipResultKo(EquipResult result, int slotIndex) {
// 기본 장비 이름 번역
String baseName;
if (slotIndex == 0) {
baseName = weaponTranslationsKo[result.baseName] ?? result.baseName;
} else if (slotIndex == 1) {
baseName = shieldTranslationsKo[result.baseName] ?? result.baseName;
} else {
baseName = armorTranslationsKo[result.baseName] ?? result.baseName;
}
// 수식어 번역 (공격용 vs 방어용)
final isWeapon = slotIndex == 0;
final translatedModifiers = result.modifiers.map((mod) {
if (isWeapon) {
// 공격 속성: offenseAttrib 또는 offenseBad
return offenseAttribTranslationsKo[mod] ??
offenseBadTranslationsKo[mod] ??
mod;
} else {
// 방어 속성: defenseAttrib 또는 defenseBad
return defenseAttribTranslationsKo[mod] ??
defenseBadTranslationsKo[mod] ??
mod;
}
}).toList();
// 조합: 수식어들 + 기본 이름
var name = baseName;
for (final mod in translatedModifiers) {
name = '$mod $name';
}
// +/- 수치 추가
if (result.plusValue != 0) {
final sign = result.plusValue > 0 ? '+' : '';
name = '$sign${result.plusValue} $name';
}
return name;
}
/// 구조화된 아이템 결과를 로컬라이즈된 문자열로 렌더링
/// 예: ItemResult(attrib: "Golden", special: "Iterator", itemOf: "Compilation")
/// → 영어: "Golden Iterator of Compilation"
/// → 한국어: "컴파일의 황금 이터레이터"
static String renderItemResult(BuildContext context, ItemResult result) {
if (_isKorean(context)) {
return _renderItemResultKo(result);
}
return result.displayName;
}
/// 한국어 아이템 렌더링 (내부 함수)
static String _renderItemResultKo(ItemResult result) {
// 단순 아이템 (boringItem)
if (result.boringItem != null) {
return boringItemTranslationsKo[result.boringItem] ?? result.boringItem!;
}
// 복합 아이템: attrib + special + itemOf
final attrib = result.attrib != null
? (itemAttribTranslationsKo[result.attrib] ?? result.attrib!)
: null;
final special = result.special != null
? (specialTranslationsKo[result.special] ?? result.special!)
: null;
final itemOf = result.itemOf != null
? (itemOfsTranslationsKo[result.itemOf] ?? result.itemOf!)
: null;
// 한국어 어순: "X의 Y" 패턴
// "Golden Iterator of Compilation" → "컴파일의 황금 이터레이터"
if (attrib != null && special != null && itemOf != null) {
return '$itemOf의 $attrib $special';
}
// attrib + special만 있는 경우
if (attrib != null && special != null) {
return '$attrib $special';
}
return '';
}
/// 장비 이름 문자열 파싱 후 번역 (기존 저장 데이터 호환)
/// 예: "+2 Optimized GPU-Powered Keyboard" → "+2 최적화된 GPU 파워 키보드"
static String translateEquipString(
BuildContext context,
String equipString,
int slotIndex,
) {
final isKo = _isKorean(context);
final isJa = _isJapanese(context);
if ((!isKo && !isJa) || equipString.isEmpty) return equipString;
// 1. +/- 값 추출
final plusMatch = RegExp(r'^([+-]?\d+)\s+').firstMatch(equipString);
String remaining = equipString;
String plusPart = '';
if (plusMatch != null) {
plusPart = plusMatch.group(1)!;
remaining = equipString.substring(plusMatch.end);
}
// 2. 기본 장비 이름 찾기 (가장 긴 매칭 우선)
// 통합 맵 사용 (추가 번역 포함)
final Map<String, String> baseMap;
if (isKo) {
if (slotIndex == 0) {
baseMap = weaponTranslationsKo;
} else if (slotIndex == 1) {
baseMap = allShieldTranslationsKo;
} else {
baseMap = allArmorTranslationsKo;
}
} else {
if (slotIndex == 0) {
baseMap = weaponTranslationsJa;
} else if (slotIndex == 1) {
baseMap = allShieldTranslationsJa;
} else {
baseMap = allArmorTranslationsJa;
}
}
String baseTranslated = remaining;
String modifierPart = '';
// 기본 장비 이름을 뒤에서부터 찾기
for (final entry in baseMap.entries) {
if (remaining.endsWith(entry.key)) {
baseTranslated = entry.value;
modifierPart = remaining
.substring(0, remaining.length - entry.key.length)
.trim();
break;
}
}
// 3. 수식어 번역
final isWeapon = slotIndex == 0;
final modWords = modifierPart
.split(' ')
.where((s) => s.isNotEmpty)
.toList();
final translatedMods = modWords.map((mod) {
if (isKo) {
if (isWeapon) {
return offenseAttribTranslationsKo[mod] ??
offenseBadTranslationsKo[mod] ??
mod;
} else {
return defenseAttribTranslationsKo[mod] ??
defenseBadTranslationsKo[mod] ??
mod;
}
} else {
if (isWeapon) {
return offenseAttribTranslationsJa[mod] ??
offenseBadTranslationsJa[mod] ??
mod;
} else {
return defenseAttribTranslationsJa[mod] ??
defenseBadTranslationsJa[mod] ??
mod;
}
}
}).toList();
// 4. 조합
var result = baseTranslated;
for (final mod in translatedMods.reversed) {
result = '$mod $result';
}
if (plusPart.isNotEmpty) {
result = '$plusPart $result';
}
return result;
}
/// 아이템 이름 문자열 파싱 후 번역 (기존 저장 데이터 호환)
/// 예: "Golden Iterator of Compilation" → "컴파일의 황금 이터레이터"
/// 예: "index out of bounds Array fragment" → "인덱스 초과의 배열 조각"
static String translateItemString(BuildContext context, String itemString) {
final isKo = _isKorean(context);
final isJa = _isJapanese(context);
if ((!isKo && !isJa) || itemString.isEmpty) return itemString;
// 1. specialItem 형식 체크: "Attrib Special of ItemOf"
// itemOfs에 있는 값으로 끝나는지 확인
final specialItemResult = _tryTranslateSpecialItem(itemString, isKo);
if (specialItemResult != null) return specialItemResult;
// 2. 몬스터 드롭 형식 체크: "{monster_lowercase} {drop_ProperCase}"
final monsterDropResult = _tryTranslateMonsterDrop(itemString, isKo);
if (monsterDropResult != null) return monsterDropResult;
// 3. interestingItem 형식: "Attrib Special" (2단어)
final words = itemString.split(' ');
if (words.length == 2) {
final attrib = words[0];
final special = words[1];
if (isKo) {
final attribKo = itemAttribTranslationsKo[attrib] ?? attrib;
final specialKo = specialTranslationsKo[special] ?? special;
return '$attribKo $specialKo';
} else {
final attribJa = itemAttribTranslationsJa[attrib] ?? attrib;
final specialJa = specialTranslationsJa[special] ?? special;
return '$attribJa $specialJa';
}
}
// 4. 단일 단어 (boringItem 등) - 잡템 번역 시도
if (isKo) {
return boringItemTranslationsKo[itemString] ??
allDropTranslationsKo[itemString.toLowerCase()] ??
itemString;
} else {
return boringItemTranslationsJa[itemString] ??
allDropTranslationsJa[itemString.toLowerCase()] ??
itemString;
}
}
/// specialItem 형식 번역 시도
/// "Attrib Special of ItemOf" → "ItemOf의 Attrib Special"
static String? _tryTranslateSpecialItem(String itemString, bool isKo) {
// "of" 뒤의 부분이 itemOfs에 있는지 확인
final ofMatch = RegExp(r'^(.+)\s+of\s+(.+)$').firstMatch(itemString);
if (ofMatch == null) return null;
final beforeOf = ofMatch.group(1)!;
final afterOf = ofMatch.group(2)!;
// afterOf가 itemOfs에 있어야 specialItem 형식
final itemOfsMap = isKo ? itemOfsTranslationsKo : itemOfsTranslationsJa;
if (!itemOfsMap.containsKey(afterOf)) return null;
// beforeOf를 Attrib + Special로 분리
final words = beforeOf.split(' ');
if (words.length < 2) return null;
final attrib = words.sublist(0, words.length - 1).join(' ');
final special = words.last;
// Attrib와 Special이 유효한지 확인
final attribMap = isKo
? itemAttribTranslationsKo
: itemAttribTranslationsJa;
final specialMap = isKo ? specialTranslationsKo : specialTranslationsJa;
if (!attribMap.containsKey(attrib) && !specialMap.containsKey(special)) {
return null;
}
final attribT = attribMap[attrib] ?? attrib;
final specialT = specialMap[special] ?? special;
final itemOfT = itemOfsMap[afterOf] ?? afterOf;
if (isKo) {
return '$itemOfT의 $attribT $specialT';
} else {
return '$itemOfTの$attribT $specialT';
}
}
/// 몬스터 드롭 형식 번역 시도
/// "{monster_lowercase} {drop_ProperCase}" → "{몬스터}의 {드롭아이템}"
static String? _tryTranslateMonsterDrop(String itemString, bool isKo) {
// 드롭 아이템 번역 맵 선택 (통합 맵 사용)
final dropMap = isKo ? allDropTranslationsKo : allDropTranslationsJa;
final monsterMap = isKo
? allMonsterTranslationsKo
: allMonsterTranslationsJa;
// (대소문자 무시, 아이템 문자열 끝에서 매칭)
for (final entry in dropMap.entries) {
final dropItem = entry.key;
final dropItemProperCase = _properCase(dropItem);
// 아이템 문자열이 드롭 아이템으로 끝나는지 확인
if (itemString.endsWith(dropItemProperCase) ||
itemString.endsWith(dropItem)) {
// 드롭 아이템 앞 부분이 몬스터 이름
String monsterPart;
if (itemString.endsWith(dropItemProperCase)) {
monsterPart = itemString
.substring(0, itemString.length - dropItemProperCase.length)
.trim();
} else {
monsterPart = itemString
.substring(0, itemString.length - dropItem.length)
.trim();
}
if (monsterPart.isEmpty) continue;
// 몬스터 이름 번역 (소문자를 원래 형태로 변환하여 찾기)
final monsterNameKey = _toTitleCase(monsterPart);
final monsterT = monsterMap[monsterNameKey] ?? monsterPart;
final dropT = entry.value;
if (isKo) {
return '$monsterT의 $dropT';
} else {
return '$monsterTの$dropT';
}
}
}
return null;
}
/// 첫 글자만 대문자로 (나머지는 그대로)
static String _properCase(String s) {
if (s.isEmpty) return s;
return s[0].toUpperCase() + s.substring(1);
}
/// 각 단어의 첫 글자를 대문자로 (Title Case)
/// 하이픈으로 연결된 단어도 처리 (예: "off-by-one" → "Off-by-One")
static String _toTitleCase(String s) {
return s
.split(' ')
.map((word) {
if (word.isEmpty) return word;
// 하이픈 포함 단어 처리
if (word.contains('-')) {
return word
.split('-')
.map((part) {
if (part.isEmpty) return part;
return part[0].toUpperCase() + part.substring(1);
})
.join('-');
}
return word[0].toUpperCase() + word.substring(1);
})
.join(' ');
}
}