Compare commits
5 Commits
c420331300
...
1da377c127
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1da377c127 | ||
|
|
f65bab6312 | ||
|
|
d52dea56ea | ||
|
|
f89017e5ba | ||
|
|
4e9265ab87 |
@@ -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': '神モードアクティベーター',
|
||||
};
|
||||
|
||||
/// 鎧名日本語翻訳
|
||||
|
||||
@@ -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': '신 모드 활성기',
|
||||
};
|
||||
|
||||
/// 갑옷 이름 한국어 번역
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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 바
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
219
lib/src/shared/widgets/ascii_disintegrate_widget.dart
Normal file
219
lib/src/shared/widgets/ascii_disintegrate_widget.dart
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user