Compare commits

...

5 Commits

Author SHA1 Message Date
JiWoong Sul
1da377c127 feat(ui): 화면 및 공통 위젯 개선
- FrontScreen 개선
- GamePlayScreen, GameSessionController 업데이트
- ArenaBattleScreen, NewCharacterScreen 정리
- AsciiDisintegrateWidget 추가
2026-01-14 00:18:16 +09:00
JiWoong Sul
f65bab6312 feat(ui): 게임 위젯 개선
- AsciiAnimationCard 확장
- EnhancedAnimationPanel 개선
- HpMpBar UI 개선
2026-01-14 00:18:10 +09:00
JiWoong Sul
d52dea56ea feat(i18n): 다국어 번역 확장
- game_data_l10n 개선
- 일본어, 한국어 번역 추가
2026-01-14 00:18:04 +09:00
JiWoong Sul
f89017e5ba feat(core): 엔진, 모델, 애니메이션 개선
- ProgressService 로직 개선
- CombatCalculator 업데이트
- GameState, MonsterCombatStats 확장
- CanvasBattleComposer 개선
2026-01-14 00:17:59 +09:00
JiWoong Sul
4e9265ab87 refactor(audio): 볼륨 0일 때 재생 스킵 및 풀 크기 조정 2026-01-14 00:17:51 +09:00
19 changed files with 773 additions and 174 deletions

View File

@@ -510,6 +510,17 @@ const Map<String, String> weaponTranslationsJa = {
'Dyson Sphere Core': 'ダイソン球コア',
'Black Hole Computer': 'ブラックホールコンピューター',
'Universe Simulator': '宇宙シミュレーター',
'Dimensional Gateway': '次元の門',
'Time Loop Device': 'タイムループデバイス',
'Reality Compiler': 'リアリティコンパイラー',
'Multiverse Bridge': 'マルチバースブリッジ',
'Cosmic Debugger': 'コズミックデバッガー',
'Entropy Reverser': 'エントロピーリバーサー',
'Big Bang Trigger': 'ビッグバントリガー',
'Heat Death Preventer': '熱的死防止装置',
'Infinity Engine': '無限エンジン',
'Omniscience Module': '全知モジュール',
'God Mode Activator': '神モードアクティベーター',
};
/// 鎧名日本語翻訳

View File

@@ -510,6 +510,17 @@ const Map<String, String> weaponTranslationsKo = {
'Dyson Sphere Core': '다이슨 구 코어',
'Black Hole Computer': '블랙홀 컴퓨터',
'Universe Simulator': '우주 시뮬레이터',
'Dimensional Gateway': '차원의 관문',
'Time Loop Device': '시간 루프 장치',
'Reality Compiler': '현실 컴파일러',
'Multiverse Bridge': '다중 우주 다리',
'Cosmic Debugger': '우주 디버거',
'Entropy Reverser': '엔트로피 역전기',
'Big Bang Trigger': '빅뱅 트리거',
'Heat Death Preventer': '열 죽음 방지기',
'Infinity Engine': '무한 엔진',
'Omniscience Module': '전지 모듈',
'God Mode Activator': '신 모드 활성기',
};
/// 갑옷 이름 한국어 번역

View File

@@ -50,6 +50,8 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
late final SettingsRepository _settingsRepository;
late final AudioService _audioService;
late final HallOfFameStorage _hallOfFameStorage;
final RouteObserver<ModalRoute<void>> _routeObserver =
RouteObserver<ModalRoute<void>>();
bool _isCheckingSave = true;
bool _hasSave = false;
SavedGamePreview? _savedGamePreview;
@@ -437,6 +439,7 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
theme: _lightTheme,
darkTheme: _darkTheme,
themeMode: _themeMode,
navigatorObservers: [_routeObserver],
builder: (context, child) {
// 현재 로케일을 게임 텍스트 l10n 시스템에 동기화
final locale = Localizations.localeOf(context);
@@ -465,6 +468,11 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
hasSaveFile: _hasSave,
savedGamePreview: _savedGamePreview,
hallOfFameCount: _hallOfFame.count,
routeObserver: _routeObserver,
onRefresh: () {
_checkForExistingSave();
_loadHallOfFame();
},
);
}
@@ -480,8 +488,9 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
),
)
.then((_) {
// 새 게임 후 돌아오면 세이브 정보 갱신 (BGM은 _checkForExistingSave에서 재생)
// 새 게임 후 돌아오면 세이브 정보 및 명예의 전당 갱신
_checkForExistingSave();
_loadHallOfFame();
});
}
@@ -560,8 +569,9 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
),
)
.then((_) {
// 게임에서 돌아오면 세이브 정보 갱신 (BGM은 _checkForExistingSave에서 재생)
// 게임에서 돌아오면 세이브 정보 및 명예의 전당 갱신
_checkForExistingSave();
_loadHallOfFame();
});
}
@@ -574,7 +584,8 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
),
)
.then((_) {
// 명예의 전당에서 돌아오면 타이틀 BGM 재생
// 명예의 전당에서 돌아오면 명예의 전당 갱신 및 타이틀 BGM 재생
_loadHallOfFame();
_audioService.playBgm('title');
});
}

View File

@@ -68,15 +68,17 @@ class CanvasBattleComposer {
bool isDot = false,
bool isBlock = false,
bool isParry = false,
bool hideMonster = false,
}) {
final layers = <AsciiLayer>[
_createBackgroundLayer(environment, globalTick),
_createCharacterLayer(phase, subFrame, attacker),
// PvP 모드: 몬스터 대신 상대 캐릭터(좌우 반전) 표시
if (isPvP)
_createOpponentCharacterLayer(phase, subFrame, attacker)
else
_createMonsterLayer(phase, subFrame, attacker),
// hideMonster: 몬스터 사망 애니메이션 중에는 렌더링 안함
if (!hideMonster)
isPvP
? _createOpponentCharacterLayer(phase, subFrame, attacker)
: _createMonsterLayer(phase, subFrame, attacker),
];
// 이펙트 레이어 (준비/공격/히트 페이즈에서, 공격자 있을 때)
@@ -1460,3 +1462,13 @@ const _parryTextFrames = <List<String>>[
[r'*PARRY!*', r'========'],
[r'=PARRY!=', r'********'],
];
/// 몬스터 Idle 프레임 가져오기 (외부에서 접근 가능)
///
/// 몬스터 사망 애니메이션에서 분해할 프레임을 가져올 때 사용
List<List<String>> getMonsterIdleFrames(
MonsterCategory category,
MonsterSize size,
) {
return _getMonsterIdleFrames(category, size);
}

View File

@@ -216,6 +216,7 @@ class AudioService {
Future<void> playBgm(String name) async {
if (_isPaused) return;
if (!_staticInitialized) await init();
if (_bgmVolume == 0) return; // 볼륨 0이면 재생 안함
if (_currentBgm == name) return;
if (_staticBgmPlayer == null) return;
@@ -376,6 +377,7 @@ class AudioService {
/// 플레이어 이펙트 SFX 재생
Future<void> playPlayerSfx(String name) async {
if (_isPaused) return;
if (_sfxVolume == 0) return; // 볼륨 0이면 재생 안함
if (!_staticInitialized) await init();
_tryPlayPendingBgm();
await _playerSfxPool?.play('assets/audio/sfx/$name.mp3');
@@ -384,6 +386,7 @@ class AudioService {
/// 몬스터 이펙트 SFX 재생
Future<void> playMonsterSfx(String name) async {
if (_isPaused) return;
if (_sfxVolume == 0) return; // 볼륨 0이면 재생 안함
if (!_staticInitialized) await init();
_tryPlayPendingBgm();
await _monsterSfxPool?.play('assets/audio/sfx/$name.mp3');
@@ -412,7 +415,13 @@ class AudioService {
_bgmVolume = volume.clamp(0.0, 1.0);
if (_staticBgmPlayer != null) {
try {
await _staticBgmPlayer!.setVolume(_bgmVolume);
// 볼륨 0이면 BGM 정지
if (_bgmVolume == 0) {
await _staticBgmPlayer!.stop();
_currentBgm = null;
} else {
await _staticBgmPlayer!.setVolume(_bgmVolume);
}
} catch (_) {}
}
await _settingsRepository.saveBgmVolume(_bgmVolume);

View File

@@ -98,9 +98,9 @@ class CombatCalculator {
final isParried = parryRoll < defenderParryRate;
// 3. 기본 데미지 계산 (0.8 ~ 1.2 변동)
// DEF 감산 비율: 0.5 (방어력 효과 상향, 몬스터 ATK 하향과 연동)
// DEF 감산 비율: 0.4 (전체 데미지 상승 조정)
final damageVariation = 0.8 + rng.nextDouble() * 0.4;
var baseDamage = (attackerAtk * damageVariation - defenderDef * 0.5);
var baseDamage = (attackerAtk * damageVariation - defenderDef * 0.4);
// 4. 크리티컬 판정
final criRoll = rng.nextDouble();
@@ -207,7 +207,7 @@ class CombatCalculator {
required MonsterCombatStats monster,
}) {
// 플레이어 DPS (초당 데미지)
final playerDamagePerHit = math.max(1, player.atk - monster.def * 0.5);
final playerDamagePerHit = math.max(1, player.atk - monster.def * 0.4);
final playerHitsPerSecond = 1000 / player.attackDelayMs;
final playerDps =
playerDamagePerHit * playerHitsPerSecond * player.accuracy;
@@ -227,14 +227,14 @@ class CombatCalculator {
required MonsterCombatStats monster,
}) {
// 플레이어 예상 생존 시간
final monsterDamagePerHit = math.max(1, monster.atk - player.def * 0.5);
final monsterDamagePerHit = math.max(1, monster.atk - player.def * 0.4);
final monsterHitsPerSecond = 1000 / monster.attackDelayMs;
final monsterDps =
monsterDamagePerHit * monsterHitsPerSecond * monster.accuracy;
final playerSurvivalTime = player.hpCurrent / monsterDps;
// 몬스터 예상 생존 시간
final playerDamagePerHit = math.max(1, player.atk - monster.def * 0.5);
final playerDamagePerHit = math.max(1, player.atk - monster.def * 0.4);
final playerHitsPerSecond = 1000 / player.attackDelayMs;
final playerDps =
playerDamagePerHit * playerHitsPerSecond * player.accuracy;

View File

@@ -588,8 +588,18 @@ class ProgressService {
// 4. 최종 보스 전투 체크
// finalBossState == fighting이면 Glitch God 스폰
// 단, 레벨링 모드 중이면 일반 몬스터로 레벨업 후 재도전
if (state.progress.finalBossState == FinalBossState.fighting) {
return _startFinalBossFight(state, progress, queue);
if (state.progress.isInBossLevelingMode) {
// 레벨링 모드: 일반 몬스터 전투로 대체 (아래 MonsterTask로 진행)
} else {
// 레벨링 모드 종료 또는 첫 도전: 보스전 시작
// 레벨링 모드가 끝났으면 타이머 초기화
if (state.progress.bossLevelingEndTime != null) {
progress = progress.copyWith(clearBossLevelingEndTime: true);
}
return _startFinalBossFight(state, progress, queue);
}
}
// 5. MonsterTask 실행 (원본 678-684줄)
@@ -634,6 +644,7 @@ class ProgressService {
name: monsterResult.displayName,
level: effectiveMonsterLevel,
speedType: MonsterCombatStats.inferSpeedType(monsterResult.baseName),
plotStageCount: state.progress.plotStageCount,
);
// 전투 상태 초기화
@@ -1667,6 +1678,7 @@ class ProgressService {
/// 플레이어 사망 처리 (Phase 4)
///
/// 모든 장비 상실 및 사망 정보 기록
/// 보스전 사망 시: 장비 보호 + 5분 레벨링 모드 진입
GameState _processPlayerDeath(
GameState state, {
required String killerName,
@@ -1676,30 +1688,36 @@ class ProgressService {
final lastCombatEvents =
state.progress.currentCombat?.recentEvents ?? const [];
// 무기(슬롯 0)를 제외한 장착된 장비 중 1개를 제물로 삭제
// 장착된 비무기 슬롯 인덱스 수집 (슬롯 1~10 중 장비가 있는 것)
final equippedNonWeaponSlots = <int>[];
for (var i = 1; i < Equipment.slotCount; i++) {
if (state.equipment.getItemByIndex(i).isNotEmpty) {
equippedNonWeaponSlots.add(i);
}
}
// 보스전 사망 여부 확인 (최종 보스 fighting 상태)
final isBossDeath =
state.progress.finalBossState == FinalBossState.fighting;
// 제물로 바칠 장비 선택 및 삭제
// 보스전 사망이 아닐 경우에만 장비 손실
var newEquipment = state.equipment;
final lostCount = equippedNonWeaponSlots.isNotEmpty ? 1 : 0;
var lostCount = 0;
if (equippedNonWeaponSlots.isNotEmpty) {
// 랜덤하게 1개 슬롯 선택
final sacrificeIndex =
equippedNonWeaponSlots[state.rng.nextInt(equippedNonWeaponSlots.length)];
final slot = EquipmentSlot.values[sacrificeIndex];
if (!isBossDeath) {
// 무기(슬롯 0)를 제외한 장착된 장비 중 1개를 제물로 삭제
final equippedNonWeaponSlots = <int>[];
for (var i = 1; i < Equipment.slotCount; i++) {
if (state.equipment.getItemByIndex(i).isNotEmpty) {
equippedNonWeaponSlots.add(i);
}
}
// 해당 슬롯을 빈 장비로 교체
newEquipment = newEquipment.setItemByIndex(
sacrificeIndex,
EquipmentItem.empty(slot),
);
if (equippedNonWeaponSlots.isNotEmpty) {
lostCount = 1;
// 랜덤하게 1개 슬롯 선택
final sacrificeIndex = equippedNonWeaponSlots[
state.rng.nextInt(equippedNonWeaponSlots.length)];
final slot = EquipmentSlot.values[sacrificeIndex];
// 해당 슬롯을 빈 장비로 교체
newEquipment = newEquipment.setItemByIndex(
sacrificeIndex,
EquipmentItem.empty(slot),
);
}
}
// 사망 정보 생성 (전투 로그 포함)
@@ -1713,12 +1731,16 @@ class ProgressService {
lastCombatEvents: lastCombatEvents,
);
// 보스전 사망 시 5분 레벨링 모드 진입
final bossLevelingEndTime = isBossDeath
? DateTime.now().millisecondsSinceEpoch + (5 * 60 * 1000) // 5분
: null;
// 전투 상태 초기화 및 사망 횟수 증가
// pendingActCompletion 플래그는 유지 (Boss 리트라이를 위해)
final progress = state.progress.copyWith(
currentCombat: null,
deathCount: state.progress.deathCount + 1,
// pendingActCompletion은 copyWith에서 명시하지 않으면 기존 값 유지
bossLevelingEndTime: bossLevelingEndTime,
);
return state.copyWith(

View File

@@ -1,4 +1,5 @@
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';
@@ -18,6 +19,13 @@ class GameDataL10n {
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)) {
@@ -284,7 +292,9 @@ class GameDataL10n {
String equipString,
int slotIndex,
) {
if (!_isKorean(context) || equipString.isEmpty) return equipString;
final isKo = _isKorean(context);
final isJa = _isJapanese(context);
if ((!isKo && !isJa) || equipString.isEmpty) return equipString;
// 1. +/- 값 추출
final plusMatch = RegExp(r'^([+-]?\d+)\s+').firstMatch(equipString);
@@ -296,13 +306,24 @@ class GameDataL10n {
}
// 2. 기본 장비 이름 찾기 (가장 긴 매칭 우선)
// 통합 맵 사용 (추가 번역 포함)
final Map<String, String> baseMap;
if (slotIndex == 0) {
baseMap = weaponTranslationsKo;
} else if (slotIndex == 1) {
baseMap = shieldTranslationsKo;
if (isKo) {
if (slotIndex == 0) {
baseMap = weaponTranslationsKo;
} else if (slotIndex == 1) {
baseMap = allShieldTranslationsKo;
} else {
baseMap = allArmorTranslationsKo;
}
} else {
baseMap = armorTranslationsKo;
if (slotIndex == 0) {
baseMap = weaponTranslationsJa;
} else if (slotIndex == 1) {
baseMap = allShieldTranslationsJa;
} else {
baseMap = allArmorTranslationsJa;
}
}
String baseTranslated = remaining;
@@ -326,14 +347,26 @@ class GameDataL10n {
.where((s) => s.isNotEmpty)
.toList();
final translatedMods = modWords.map((mod) {
if (isWeapon) {
return offenseAttribTranslationsKo[mod] ??
offenseBadTranslationsKo[mod] ??
mod;
if (isKo) {
if (isWeapon) {
return offenseAttribTranslationsKo[mod] ??
offenseBadTranslationsKo[mod] ??
mod;
} else {
return defenseAttribTranslationsKo[mod] ??
defenseBadTranslationsKo[mod] ??
mod;
}
} else {
return defenseAttribTranslationsKo[mod] ??
defenseBadTranslationsKo[mod] ??
mod;
if (isWeapon) {
return offenseAttribTranslationsJa[mod] ??
offenseBadTranslationsJa[mod] ??
mod;
} else {
return defenseAttribTranslationsJa[mod] ??
defenseBadTranslationsJa[mod] ??
mod;
}
}
}).toList();
@@ -353,15 +386,17 @@ class GameDataL10n {
/// 예: "Golden Iterator of Compilation" → "컴파일의 황금 이터레이터"
/// 예: "index out of bounds Array fragment" → "인덱스 초과의 배열 조각"
static String translateItemString(BuildContext context, String itemString) {
if (!_isKorean(context) || itemString.isEmpty) return 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);
final specialItemResult = _tryTranslateSpecialItem(itemString, isKo);
if (specialItemResult != null) return specialItemResult;
// 2. 몬스터 드롭 형식 체크: "{monster_lowercase} {drop_ProperCase}"
final monsterDropResult = _tryTranslateMonsterDrop(itemString);
final monsterDropResult = _tryTranslateMonsterDrop(itemString, isKo);
if (monsterDropResult != null) return monsterDropResult;
// 3. interestingItem 형식: "Attrib Special" (2단어)
@@ -370,21 +405,32 @@ class GameDataL10n {
final attrib = words[0];
final special = words[1];
final attribKo = itemAttribTranslationsKo[attrib] ?? attrib;
final specialKo = specialTranslationsKo[special] ?? special;
return '$attribKo $specialKo';
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 등) - 잡템 번역 시도
return boringItemTranslationsKo[itemString] ??
dropItemTranslationsKo[itemString.toLowerCase()] ??
itemString;
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) {
static String? _tryTranslateSpecialItem(String itemString, bool isKo) {
// "of" 뒤의 부분이 itemOfs에 있는지 확인
final ofMatch = RegExp(r'^(.+)\s+of\s+(.+)$').firstMatch(itemString);
if (ofMatch == null) return null;
@@ -393,7 +439,8 @@ class GameDataL10n {
final afterOf = ofMatch.group(2)!;
// afterOf가 itemOfs에 있어야 specialItem 형식
if (!itemOfsTranslationsKo.containsKey(afterOf)) return null;
final itemOfsMap = isKo ? itemOfsTranslationsKo : itemOfsTranslationsJa;
if (!itemOfsMap.containsKey(afterOf)) return null;
// beforeOf를 Attrib + Special로 분리
final words = beforeOf.split(' ');
@@ -403,24 +450,32 @@ class GameDataL10n {
final special = words.last;
// Attrib와 Special이 유효한지 확인
if (!itemAttribTranslationsKo.containsKey(attrib) &&
!specialTranslationsKo.containsKey(special)) {
final attribMap = isKo ? itemAttribTranslationsKo : itemAttribTranslationsJa;
final specialMap = isKo ? specialTranslationsKo : specialTranslationsJa;
if (!attribMap.containsKey(attrib) && !specialMap.containsKey(special)) {
return null;
}
final attribKo = itemAttribTranslationsKo[attrib] ?? attrib;
final specialKo = specialTranslationsKo[special] ?? special;
final itemOfKo = itemOfsTranslationsKo[afterOf] ?? afterOf;
final attribT = attribMap[attrib] ?? attrib;
final specialT = specialMap[special] ?? special;
final itemOfT = itemOfsMap[afterOf] ?? afterOf;
return '$itemOfKo의 $attribKo $specialKo';
if (isKo) {
return '$itemOfT의 $attribT $specialT';
} else {
return '$itemOfTの$attribT $specialT';
}
}
/// 몬스터 드롭 형식 번역 시도
/// "{monster_lowercase} {drop_ProperCase}" → "{몬스터}의 {드롭아이템}"
static String? _tryTranslateMonsterDrop(String itemString) {
// dropItemTranslationsKo에서 매칭되는 드롭 아이템 찾기
static String? _tryTranslateMonsterDrop(String itemString, bool isKo) {
// 드롭 아이템 번역 맵 선택 (통합 맵 사용)
final dropMap = isKo ? allDropTranslationsKo : allDropTranslationsJa;
final monsterMap = isKo ? allMonsterTranslationsKo : allMonsterTranslationsJa;
// (대소문자 무시, 아이템 문자열 끝에서 매칭)
for (final entry in dropItemTranslationsKo.entries) {
for (final entry in dropMap.entries) {
final dropItem = entry.key;
final dropItemProperCase = _properCase(dropItem);
@@ -443,10 +498,14 @@ class GameDataL10n {
// 몬스터 이름 번역 (소문자를 원래 형태로 변환하여 찾기)
final monsterNameKey = _toTitleCase(monsterPart);
final monsterKo = monsterTranslationsKo[monsterNameKey] ?? monsterPart;
final monsterT = monsterMap[monsterNameKey] ?? monsterPart;
final dropKo = entry.value;
return '$monsterKo의 $dropKo';
final dropT = entry.value;
if (isKo) {
return '$monsterT의 $dropT';
} else {
return '$monsterTの$dropT';
}
}
}
return null;

View File

@@ -475,7 +475,8 @@ class Inventory {
final int gold;
final List<InventoryEntry> items;
factory Inventory.empty() => const Inventory(gold: 0, items: []);
/// 초기 골드 1000 지급 (캐릭터 생성 시)
factory Inventory.empty() => const Inventory(gold: 1000, items: []);
Inventory copyWith({int? gold, List<InventoryEntry>? items}) {
return Inventory(gold: gold ?? this.gold, items: items ?? this.items);
@@ -800,6 +801,7 @@ class ProgressState {
this.deathCount = 0,
this.finalBossState = FinalBossState.notSpawned,
this.pendingActCompletion = false,
this.bossLevelingEndTime,
});
final ProgressBarState task;
@@ -835,6 +837,10 @@ class ProgressState {
/// Act Boss 처치 대기 중 여부 (처치 후 시네마틱 재생 트리거)
final bool pendingActCompletion;
/// 보스 사망 후 레벨링 모드 종료 시간 (milliseconds since epoch)
/// null이면 레벨링 모드 아님, 값이 있으면 해당 시간까지 레벨링
final int? bossLevelingEndTime;
factory ProgressState.empty() => ProgressState(
task: ProgressBarState.empty(),
quest: ProgressBarState.empty(),
@@ -867,6 +873,8 @@ class ProgressState {
int? deathCount,
FinalBossState? finalBossState,
bool? pendingActCompletion,
int? bossLevelingEndTime,
bool clearBossLevelingEndTime = false,
}) {
return ProgressState(
task: task ?? this.task,
@@ -885,8 +893,17 @@ class ProgressState {
deathCount: deathCount ?? this.deathCount,
finalBossState: finalBossState ?? this.finalBossState,
pendingActCompletion: pendingActCompletion ?? this.pendingActCompletion,
bossLevelingEndTime: clearBossLevelingEndTime
? null
: (bossLevelingEndTime ?? this.bossLevelingEndTime),
);
}
/// 현재 레벨링 모드인지 확인
bool get isInBossLevelingMode {
if (bossLevelingEndTime == null) return false;
return DateTime.now().millisecondsSinceEpoch < bossLevelingEndTime!;
}
}
class QueueEntry {

View File

@@ -131,15 +131,29 @@ class MonsterCombatStats {
/// [level] 몬스터 레벨 (원본 데이터 기준)
/// [speedType] 공격 속도 타입 (기본: normal)
/// [monsterType] 몬스터 타입 (기본: normal)
/// [plotStageCount] 현재 Act (1=Prologue, 2=Act I, 3=Act II, ...)
factory MonsterCombatStats.fromLevel({
required String name,
required int level,
MonsterSpeedType speedType = MonsterSpeedType.normal,
MonsterType monsterType = MonsterType.normal,
int plotStageCount = 1,
}) {
// balance_constants.dart의 MonsterBaseStats 사용
final baseStats = MonsterBaseStats.generate(level, monsterType);
// Act II 이후 (plotStageCount >= 3) HP 10% 상승
final hpMultiplier = plotStageCount >= 3 ? 1.1 : 1.0;
final adjustedHp = (baseStats.hp * hpMultiplier).round();
// Act별 경험치 배율 (후반부 레벨업 가속)
final expMultiplier = switch (plotStageCount) {
5 => 1.3, // Act IV: 30% 보너스
6 => 1.8, // Act V: 80% 보너스 (보스전 대비)
_ => 1.0, // 기본
};
final adjustedExp = (baseStats.exp * expMultiplier).round();
// 크리티컬 확률: 레벨에 따라 천천히 증가 (0.02 ~ 0.3)
final criRate = (0.02 + level * 0.003).clamp(0.02, 0.3);
@@ -164,14 +178,14 @@ class MonsterCombatStats {
level: level,
atk: baseStats.atk,
def: baseStats.def,
hpMax: baseStats.hp,
hpCurrent: baseStats.hp,
hpMax: adjustedHp,
hpCurrent: adjustedHp,
criRate: criRate,
criDamage: criDamage,
evasion: evasion,
accuracy: accuracy,
attackDelayMs: attackDelayMs,
expReward: baseStats.exp,
expReward: adjustedExp,
);
}

View File

@@ -10,7 +10,7 @@ import 'package:asciineverdie/src/core/model/hall_of_fame.dart';
import 'package:asciineverdie/src/core/animation/race_character_frames.dart';
import 'package:asciineverdie/src/features/arena/widgets/arena_combat_log.dart';
import 'package:asciineverdie/src/features/arena/widgets/arena_result_panel.dart';
import 'package:asciineverdie/src/features/arena/widgets/ascii_disintegrate_widget.dart';
import 'package:asciineverdie/src/shared/widgets/ascii_disintegrate_widget.dart';
import 'package:asciineverdie/src/features/game/widgets/ascii_animation_card.dart';
import 'package:asciineverdie/src/features/game/widgets/combat_log.dart';
import 'package:asciineverdie/src/shared/retro_colors.dart';

View File

@@ -10,7 +10,7 @@ import 'package:asciineverdie/src/features/front/widgets/hero_vs_boss_animation.
import 'package:asciineverdie/src/shared/retro_colors.dart';
import 'package:asciineverdie/src/shared/widgets/retro_widgets.dart';
class FrontScreen extends StatelessWidget {
class FrontScreen extends StatefulWidget {
const FrontScreen({
super.key,
this.onNewCharacter,
@@ -20,6 +20,8 @@ class FrontScreen extends StatelessWidget {
this.hasSaveFile = false,
this.savedGamePreview,
this.hallOfFameCount = 0,
this.routeObserver,
this.onRefresh,
});
/// "New character" 버튼 클릭 시 호출
@@ -43,12 +45,45 @@ class FrontScreen extends StatelessWidget {
/// 명예의 전당 캐릭터 수 (아레나 활성화 조건: 2명 이상)
final int hallOfFameCount;
/// RouteObserver (화면 복귀 시 갱신용)
final RouteObserver<ModalRoute<void>>? routeObserver;
/// 화면 복귀 시 호출할 콜백
final VoidCallback? onRefresh;
@override
State<FrontScreen> createState() => _FrontScreenState();
}
class _FrontScreenState extends State<FrontScreen> with RouteAware {
@override
void didChangeDependencies() {
super.didChangeDependencies();
// RouteObserver 구독
final route = ModalRoute.of(context);
if (route != null) {
widget.routeObserver?.subscribe(this, route);
}
}
@override
void dispose() {
widget.routeObserver?.unsubscribe(this);
super.dispose();
}
@override
void didPopNext() {
// 다른 화면에서 돌아왔을 때 갱신
widget.onRefresh?.call();
}
/// 새 캐릭터 생성 시 세이브 파일 존재하면 경고 표시
void _handleNewCharacter(BuildContext context) {
if (hasSaveFile) {
if (widget.hasSaveFile) {
_showDeleteWarningDialog(context);
} else {
onNewCharacter?.call(context);
widget.onNewCharacter?.call(context);
}
}
@@ -67,7 +102,7 @@ class FrontScreen extends StatelessWidget {
FilledButton(
onPressed: () {
Navigator.pop(dialogContext);
onNewCharacter?.call(context);
widget.onNewCharacter?.call(context);
},
child: Text(game_l10n.buttonConfirm),
),
@@ -98,21 +133,21 @@ class FrontScreen extends StatelessWidget {
const _AnimationPanel(),
const SizedBox(height: 16),
_ActionButtons(
onNewCharacter: onNewCharacter != null
onNewCharacter: widget.onNewCharacter != null
? () => _handleNewCharacter(context)
: null,
onLoadSave: onLoadSave != null
? () => onLoadSave!(context)
onLoadSave: widget.onLoadSave != null
? () => widget.onLoadSave!(context)
: null,
onHallOfFame: onHallOfFame != null
? () => onHallOfFame!(context)
onHallOfFame: widget.onHallOfFame != null
? () => widget.onHallOfFame!(context)
: null,
onLocalArena:
onLocalArena != null && hallOfFameCount >= 2
? () => onLocalArena!(context)
onLocalArena: widget.onLocalArena != null &&
widget.hallOfFameCount >= 2
? () => widget.onLocalArena!(context)
: null,
savedGamePreview: savedGamePreview,
hallOfFameCount: hallOfFameCount,
savedGamePreview: widget.savedGamePreview,
hallOfFameCount: widget.hallOfFameCount,
),
],
),
@@ -262,16 +297,14 @@ class _ActionButtons extends StatelessWidget {
onPressed: onHallOfFame,
isPrimary: false,
),
// 로컬 아레나 (2명 이상일 때만 활성화)
if (hallOfFameCount >= 2) ...[
const SizedBox(height: 12),
RetroTextButton(
text: game_l10n.uiLocalArena,
icon: Icons.sports_kabaddi,
onPressed: onLocalArena,
isPrimary: false,
),
],
// 로컬 아레나 (항상 표시, 2명 이상일 때만 활성화)
const SizedBox(height: 12),
RetroTextButton(
text: game_l10n.uiLocalArena,
icon: Icons.sports_kabaddi,
onPressed: hallOfFameCount >= 2 ? onLocalArena : null,
isPrimary: false,
),
],
),
);

View File

@@ -1181,6 +1181,7 @@ class _GamePlayScreenState extends State<GamePlayScreen>
state.progress.currentCombat?.monsterStats.hpCurrent,
monsterHpMax: state.progress.currentCombat?.monsterStats.hpMax,
monsterName: state.progress.currentCombat?.monsterStats.name,
monsterLevel: state.progress.currentCombat?.monsterStats.level,
),
// Experience 바

View File

@@ -107,6 +107,14 @@ class GameSessionController extends ChangeNotifier {
if (isNewGame) {
_sessionStats = SessionStatistics.empty();
await _statisticsStorage.recordGameStart();
} else {
// 게임 로드 시 저장된 사망 횟수 복원
_sessionStats = _sessionStats.copyWith(
deathCount: state.progress.deathCount,
questsCompleted: state.progress.questCount,
monstersKilled: state.progress.monstersKilled,
playTimeMs: state.skillSystem.elapsedMs,
);
}
_initPreviousValues(state);
@@ -341,7 +349,7 @@ class GameSessionController extends ChangeNotifier {
final entry = HallOfFameEntry.fromGameState(
state: _state!,
totalDeaths: _sessionStats.deathCount,
totalDeaths: _state!.progress.deathCount, // GameState에 저장된 값 사용
monstersKilled: _state!.progress.monstersKilled,
combatStats: combatStats,
);

View File

@@ -3,6 +3,7 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:asciineverdie/src/core/animation/ascii_animation_data.dart';
import 'package:asciineverdie/src/shared/widgets/ascii_disintegrate_widget.dart';
import 'package:asciineverdie/src/core/animation/ascii_animation_type.dart';
import 'package:asciineverdie/src/core/animation/background_layer.dart';
import 'package:asciineverdie/src/core/animation/canvas/ascii_canvas_widget.dart';
@@ -47,6 +48,8 @@ class AsciiAnimationCard extends StatefulWidget {
this.monsterLevel,
this.monsterGrade,
this.isPaused = false,
this.isInCombat = true,
this.monsterDied = false,
this.latestCombatEvent,
this.raceId,
this.weaponRarity,
@@ -59,6 +62,12 @@ class AsciiAnimationCard extends StatefulWidget {
/// 일시정지 상태 (true면 애니메이션 정지)
final bool isPaused;
/// 전투 활성 상태 (false면 kill 태스크여도 walking 애니메이션)
final bool isInCombat;
/// 몬스터 사망 여부 (true면 분해 애니메이션 재생)
final bool monsterDied;
/// 전투 중인 몬스터 기본 이름 (kill 타입일 때만 사용)
final String? monsterBaseName;
final AsciiColorTheme colorTheme;
@@ -162,6 +171,11 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
// 특수 애니메이션 프레임 수는 ascii_animation_type.dart의
// specialAnimationFrameCounts 상수 사용
// 몬스터 사망 분해 애니메이션 상태
bool _showDeathAnimation = false;
List<String>? _deathAnimationMonsterLines;
String? _lastMonsterBaseName;
@override
void initState() {
super.initState();
@@ -204,12 +218,32 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
return;
}
// 몬스터 사망 애니메이션 트리거
if (!oldWidget.monsterDied && widget.monsterDied && !_showDeathAnimation) {
// 현재 몬스터 프레임 캡처 (분해 애니메이션용)
_deathAnimationMonsterLines = _captureMonsterFrame();
if (_deathAnimationMonsterLines != null) {
setState(() {
_showDeathAnimation = true;
});
return; // 사망 애니메이션 중에는 다른 업데이트 무시
}
}
// 사망 애니메이션 중에는 다른 업데이트 무시
if (_showDeathAnimation) return;
// 전투 이벤트 동기화 (Phase 5)
if (widget.latestCombatEvent != null &&
widget.latestCombatEvent!.timestamp != _lastEventTimestamp) {
_handleCombatEvent(widget.latestCombatEvent!);
}
// 몬스터 이름 저장 (사망 시 프레임 캡처용)
if (widget.monsterBaseName != null) {
_lastMonsterBaseName = widget.monsterBaseName;
}
if (oldWidget.taskType != widget.taskType ||
oldWidget.monsterBaseName != widget.monsterBaseName ||
oldWidget.weaponName != widget.weaponName ||
@@ -218,7 +252,8 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
oldWidget.raceId != widget.raceId ||
oldWidget.weaponRarity != widget.weaponRarity ||
oldWidget.opponentRaceId != widget.opponentRaceId ||
oldWidget.opponentHasShield != widget.opponentHasShield) {
oldWidget.opponentHasShield != widget.opponentHasShield ||
oldWidget.isInCombat != widget.isInCombat) {
_updateAnimation();
}
}
@@ -505,12 +540,18 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
switch (animationType) {
case AsciiAnimationType.battle:
_animationMode = AnimationMode.battle;
_setupBattleComposer();
_battlePhase = BattlePhase.idle;
_battleSubFrame = 0;
_phaseIndex = 0;
_phaseFrameCount = 0;
// 전투 비활성 상태면 walking 모드로 전환 (몬스터 처치 후 이동 중)
if (!widget.isInCombat) {
_animationMode = AnimationMode.walking;
_walkingComposer = CanvasWalkingComposer(raceId: widget.raceId);
} else {
_animationMode = AnimationMode.battle;
_setupBattleComposer();
_battlePhase = BattlePhase.idle;
_battleSubFrame = 0;
_phaseIndex = 0;
_phaseFrameCount = 0;
}
case AsciiAnimationType.town:
_animationMode = AnimationMode.town;
@@ -556,6 +597,31 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
);
}
/// 현재 몬스터 프레임을 텍스트 라인으로 캡처 (분해 애니메이션용)
List<String>? _captureMonsterFrame() {
final monsterName = _lastMonsterBaseName ?? widget.monsterBaseName;
if (monsterName == null) return null;
final monsterCategory = getMonsterCategory(monsterName);
final monsterSize = getMonsterSize(widget.monsterLevel);
// 몬스터 Idle 프레임 가져오기
final frames = getMonsterIdleFrames(monsterCategory, monsterSize);
if (frames.isEmpty) return null;
return frames.first;
}
/// 사망 애니메이션 완료 콜백
void _onDeathAnimationComplete() {
setState(() {
_showDeathAnimation = false;
_deathAnimationMonsterLines = null;
});
// Walking 모드로 전환
_updateAnimation();
}
void _advanceBattleFrame() {
_phaseFrameCount++;
final currentPhase = _battlePhaseSequence[_phaseIndex];
@@ -617,6 +683,7 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
isDot: _showDotEffect,
isBlock: _showBlockEffect,
isParry: _showParryEffect,
hideMonster: _showDeathAnimation,
) ??
[AsciiLayer.empty()],
AnimationMode.walking =>
@@ -676,7 +743,26 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
borderRadius: BorderRadius.circular(4),
border: borderEffect,
),
child: AsciiCanvasWidget(layers: _composeLayers()),
child: Stack(
children: [
// 기본 애니메이션
AsciiCanvasWidget(layers: _composeLayers()),
// 몬스터 사망 분해 애니메이션 오버레이
// 몬스터 위치: 캔버스 60열 중 30~48열 (중앙값 41열)
// Alignment x = (41/60) * 2 - 1 = 0.37
if (_showDeathAnimation && _deathAnimationMonsterLines != null)
Align(
alignment: const Alignment(0.37, 0.0),
child: AsciiDisintegrateWidget(
characterLines: _deathAnimationMonsterLines!,
duration: const Duration(milliseconds: 800),
textColor: widget.monsterGrade?.displayColor,
onComplete: _onDeathAnimationComplete,
),
),
],
),
);
}
}

View File

@@ -83,6 +83,10 @@ class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
int _lastMp = 0;
int _lastMonsterHp = 0;
// 몬스터 사망 상태 추적
bool _monsterDied = false;
bool _wasInCombat = false;
@override
void initState() {
super.initState();
@@ -145,6 +149,27 @@ class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
} else if (newMonsterHp == null) {
_lastMonsterHp = 0;
}
// 몬스터 사망 감지: 전투 중 → 비전투 전환 시 몬스터 사망
final combat = widget.progress.currentCombat;
final isNowInCombat = combat != null && combat.isActive;
if (_wasInCombat && !isNowInCombat) {
// 전투가 끝났고, 태스크가 여전히 kill이면 몬스터 사망 (플레이어 승리)
if (widget.progress.currentTask.type == TaskType.kill) {
setState(() {
_monsterDied = true;
});
// 잠시 후 리셋 (애니메이션 완료 후)
Future.delayed(const Duration(milliseconds: 900), () {
if (mounted) {
setState(() {
_monsterDied = false;
});
}
});
}
}
_wasInCombat = isNowInCombat;
}
int get _currentHp =>
@@ -197,6 +222,8 @@ class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
monsterLevel: widget.monsterLevel,
monsterGrade: widget.monsterGrade,
isPaused: widget.isPaused,
isInCombat: isInCombat,
monsterDied: _monsterDied,
latestCombatEvent: widget.latestCombatEvent,
raceId: widget.raceId,
weaponRarity: widget.weaponRarity,
@@ -480,10 +507,14 @@ class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
}
/// 몬스터 HP 바 (전투 중)
/// - HP바 중앙에 HP% 오버레이
/// - 하단에 레벨.이름 표시
Widget _buildMonsterHpBar(CombatState combat) {
final max = _currentMonsterHpMax ?? 1;
final current = _currentMonsterHp ?? 0;
final ratio = max > 0 ? current / max : 0.0;
final monsterName = combat.monsterStats.name;
final monsterLevel = widget.monsterLevel ?? combat.monsterStats.level;
return AnimatedBuilder(
animation: _monsterFlashAnimation,
@@ -492,7 +523,7 @@ class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
clipBehavior: Clip.none,
children: [
Container(
height: 32,
height: 36,
decoration: BoxDecoration(
color: Colors.orange.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(4),
@@ -501,27 +532,58 @@ class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// HP 바
// HP 바 (HP% 중앙 오버레이)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: ClipRRect(
borderRadius: BorderRadius.circular(2),
child: LinearProgressIndicator(
value: ratio.clamp(0.0, 1.0),
backgroundColor: Colors.orange.withValues(alpha: 0.2),
valueColor: const AlwaysStoppedAnimation(Colors.orange),
minHeight: 8,
),
child: Stack(
alignment: Alignment.center,
children: [
// HP 바
ClipRRect(
borderRadius: BorderRadius.circular(2),
child: LinearProgressIndicator(
value: ratio.clamp(0.0, 1.0),
backgroundColor: Colors.orange.withValues(
alpha: 0.2,
),
valueColor: const AlwaysStoppedAnimation(
Colors.orange,
),
minHeight: 12,
),
),
// HP% 중앙 오버레이
Text(
'${(ratio * 100).toInt()}%',
style: TextStyle(
fontSize: 9,
fontWeight: FontWeight.bold,
color: Colors.white,
shadows: [
Shadow(
color: Colors.black.withValues(alpha: 0.8),
blurRadius: 2,
),
const Shadow(color: Colors.black, blurRadius: 4),
],
),
),
],
),
),
const SizedBox(height: 2),
// 퍼센트
Text(
'${(ratio * 100).toInt()}%',
style: const TextStyle(
fontSize: 9,
color: Colors.orange,
fontWeight: FontWeight.bold,
// 레벨.이름 표시
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Text(
'Lv.$monsterLevel $monsterName',
style: const TextStyle(
fontSize: 9,
color: Colors.orange,
fontWeight: FontWeight.bold,
),
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
),
],

View File

@@ -19,6 +19,7 @@ class HpMpBar extends StatefulWidget {
this.monsterHpCurrent,
this.monsterHpMax,
this.monsterName,
this.monsterLevel,
});
final int hpCurrent;
@@ -30,6 +31,7 @@ class HpMpBar extends StatefulWidget {
final int? monsterHpCurrent;
final int? monsterHpMax;
final String? monsterName;
final int? monsterLevel;
@override
State<HpMpBar> createState() => _HpMpBarState();
@@ -368,11 +370,17 @@ class _HpMpBarState extends State<HpMpBar> with TickerProviderStateMixin {
}
/// 몬스터 HP 바 (레트로 스타일)
/// - HP바 중앙에 HP% 오버레이
/// - 하단에 레벨.이름 표시
Widget _buildMonsterBar() {
final max = widget.monsterHpMax!;
final ratio = max > 0 ? widget.monsterHpCurrent! / max : 0.0;
const segmentCount = 10;
final filledSegments = (ratio.clamp(0.0, 1.0) * segmentCount).round();
final levelPrefix = widget.monsterLevel != null
? 'Lv.${widget.monsterLevel} '
: '';
final monsterName = widget.monsterName ?? '';
return AnimatedBuilder(
animation: _monsterFlashAnimation,
@@ -396,62 +404,78 @@ class _HpMpBarState extends State<HpMpBar> with TickerProviderStateMixin {
child: Stack(
clipBehavior: Clip.none,
children: [
Row(
Column(
mainAxisSize: MainAxisSize.min,
children: [
// 몬스터 아이콘
const Icon(
Icons.pest_control,
size: 12,
color: RetroColors.gold,
),
const SizedBox(width: 6),
// 세그먼트 HP 바
Expanded(
child: Container(
height: 10,
decoration: BoxDecoration(
color: RetroColors.hpRedDark.withValues(alpha: 0.3),
border: Border.all(
color: RetroColors.panelBorderOuter,
width: 1,
// HP 바 (HP% 중앙 오버레이)
Stack(
alignment: Alignment.center,
children: [
// 세그먼트 HP 바
Container(
height: 12,
decoration: BoxDecoration(
color: RetroColors.hpRedDark.withValues(alpha: 0.3),
border: Border.all(
color: RetroColors.panelBorderOuter,
width: 1,
),
),
),
child: Row(
children: List.generate(segmentCount, (index) {
final isFilled = index < filledSegments;
return Expanded(
child: Container(
decoration: BoxDecoration(
color: isFilled
? RetroColors.gold
: RetroColors.panelBorderOuter.withValues(
alpha: 0.3,
),
border: Border(
right: index < segmentCount - 1
? BorderSide(
color: RetroColors.panelBorderOuter
.withValues(alpha: 0.3),
width: 1,
)
: BorderSide.none,
child: Row(
children: List.generate(segmentCount, (index) {
final isFilled = index < filledSegments;
return Expanded(
child: Container(
decoration: BoxDecoration(
color: isFilled
? RetroColors.gold
: RetroColors.panelBorderOuter.withValues(
alpha: 0.3,
),
border: Border(
right: index < segmentCount - 1
? BorderSide(
color: RetroColors.panelBorderOuter
.withValues(alpha: 0.3),
width: 1,
)
: BorderSide.none,
),
),
),
),
);
}),
);
}),
),
),
),
// HP% 중앙 오버레이
Text(
'${(ratio * 100).toInt()}%',
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 8,
color: RetroColors.textLight,
shadows: [
Shadow(
color: Colors.black.withValues(alpha: 0.8),
blurRadius: 2,
),
const Shadow(color: Colors.black, blurRadius: 4),
],
),
),
],
),
const SizedBox(width: 6),
// HP 퍼센트
const SizedBox(height: 4),
// 레벨.이름 표시
Text(
'${(ratio * 100).toInt()}%',
'$levelPrefix$monsterName',
style: const TextStyle(
fontFamily: 'PressStart2P',
fontSize: 8,
fontSize: 7,
color: RetroColors.gold,
),
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
],
),

View File

@@ -259,7 +259,7 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
seed: gameSeed,
traits: traits,
stats: finalStats,
inventory: const Inventory(gold: 0, items: []),
inventory: Inventory.empty(),
equipment: Equipment.empty(),
skillBook: SkillBook.empty(),
progress: ProgressState.empty(),

View File

@@ -0,0 +1,219 @@
import 'dart:math';
import 'package:flutter/material.dart';
/// ASCII 문자 분해 파티클
class AsciiParticle {
AsciiParticle({
required this.char,
required this.initialX,
required this.initialY,
required this.vx,
required this.vy,
required this.delay,
}) : x = initialX,
y = initialY,
opacity = 1.0;
final String char;
final double initialX;
final double initialY;
final double vx; // X 속도
final double vy; // Y 속도
final double delay; // 분해 시작 지연 (0.0 ~ 0.3)
double x;
double y;
double opacity;
/// 진행도에 따라 파티클 상태 업데이트
void update(double progress) {
// 지연 적용
final adjustedProgress = ((progress - delay) / (1.0 - delay)).clamp(
0.0,
1.0,
);
if (adjustedProgress <= 0) {
// 아직 분해 시작 전
x = initialX;
y = initialY;
opacity = 1.0;
return;
}
// 이징 적용 (가속)
final easedProgress = Curves.easeOutQuad.transform(adjustedProgress);
// 위치 업데이트 (초기 위치에서 이동)
x = initialX + vx * easedProgress * 3.0;
y = initialY + vy * easedProgress * 3.0;
// 중력 효과
y += easedProgress * easedProgress * 2.0;
// 페이드 아웃 (후반부에 급격히)
opacity = (1.0 - easedProgress * easedProgress).clamp(0.0, 1.0);
}
}
/// ASCII 캐릭터 분해 애니메이션 위젯
///
/// 캐릭터의 각 ASCII 문자가 파티클로 분해되어 흩어지는 효과
class AsciiDisintegrateWidget extends StatefulWidget {
const AsciiDisintegrateWidget({
super.key,
required this.characterLines,
this.charWidth = 8.0,
this.charHeight = 12.0,
this.duration = const Duration(milliseconds: 1500),
this.textColor,
this.onComplete,
});
/// ASCII 캐릭터 문자열 (줄 단위)
final List<String> characterLines;
/// 문자 너비 (픽셀)
final double charWidth;
/// 문자 높이 (픽셀)
final double charHeight;
/// 애니메이션 지속 시간
final Duration duration;
/// 텍스트 색상 (null이면 테마 색상)
final Color? textColor;
/// 완료 콜백
final VoidCallback? onComplete;
@override
State<AsciiDisintegrateWidget> createState() =>
_AsciiDisintegrateWidgetState();
}
class _AsciiDisintegrateWidgetState extends State<AsciiDisintegrateWidget>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late List<AsciiParticle> _particles;
final Random _random = Random();
@override
void initState() {
super.initState();
_initParticles();
_controller = AnimationController(duration: widget.duration, vsync: this)
..addListener(() => setState(() {}))
..addStatusListener((status) {
if (status == AnimationStatus.completed) {
widget.onComplete?.call();
}
})
..forward();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _initParticles() {
_particles = [];
for (int y = 0; y < widget.characterLines.length; y++) {
final line = widget.characterLines[y];
for (int x = 0; x < line.length; x++) {
final char = line[x];
// 공백은 파티클로 변환하지 않음
if (char != ' ') {
_particles.add(
AsciiParticle(
char: char,
initialX: x.toDouble(),
initialY: y.toDouble(),
// 랜덤 속도 (위쪽 + 좌우로 퍼짐)
vx: (_random.nextDouble() - 0.5) * 4.0,
vy: -_random.nextDouble() * 2.0 - 0.5, // 위쪽으로
// 랜덤 지연 (안쪽에서 바깥쪽으로 분해)
delay: _random.nextDouble() * 0.3,
),
);
}
}
}
}
@override
Widget build(BuildContext context) {
// 파티클 상태 업데이트
for (final particle in _particles) {
particle.update(_controller.value);
}
final textColor =
widget.textColor ?? Theme.of(context).textTheme.bodyMedium?.color;
return CustomPaint(
size: Size(
widget.characterLines.isNotEmpty
? widget.characterLines
.map((l) => l.length)
.reduce((a, b) => a > b ? a : b) *
widget.charWidth
: 0,
widget.characterLines.length * widget.charHeight,
),
painter: _DisintegratePainter(
particles: _particles,
charWidth: widget.charWidth,
charHeight: widget.charHeight,
textColor: textColor ?? Colors.white,
),
);
}
}
/// 분해 파티클 페인터
class _DisintegratePainter extends CustomPainter {
_DisintegratePainter({
required this.particles,
required this.charWidth,
required this.charHeight,
required this.textColor,
});
final List<AsciiParticle> particles;
final double charWidth;
final double charHeight;
final Color textColor;
@override
void paint(Canvas canvas, Size size) {
for (final particle in particles) {
if (particle.opacity <= 0) continue;
final textPainter = TextPainter(
text: TextSpan(
text: particle.char,
style: TextStyle(
fontFamily: 'JetBrainsMono',
fontSize: charHeight * 0.9,
color: textColor.withValues(alpha: particle.opacity),
),
),
textDirection: TextDirection.ltr,
)..layout();
final x = particle.x * charWidth;
final y = particle.y * charHeight;
textPainter.paint(canvas, Offset(x, y));
}
}
@override
bool shouldRepaint(covariant _DisintegratePainter oldDelegate) => true;
}