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': 'ダイソン球コア',
|
'Dyson Sphere Core': 'ダイソン球コア',
|
||||||
'Black Hole Computer': 'ブラックホールコンピューター',
|
'Black Hole Computer': 'ブラックホールコンピューター',
|
||||||
'Universe Simulator': '宇宙シミュレーター',
|
'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': '다이슨 구 코어',
|
'Dyson Sphere Core': '다이슨 구 코어',
|
||||||
'Black Hole Computer': '블랙홀 컴퓨터',
|
'Black Hole Computer': '블랙홀 컴퓨터',
|
||||||
'Universe Simulator': '우주 시뮬레이터',
|
'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 SettingsRepository _settingsRepository;
|
||||||
late final AudioService _audioService;
|
late final AudioService _audioService;
|
||||||
late final HallOfFameStorage _hallOfFameStorage;
|
late final HallOfFameStorage _hallOfFameStorage;
|
||||||
|
final RouteObserver<ModalRoute<void>> _routeObserver =
|
||||||
|
RouteObserver<ModalRoute<void>>();
|
||||||
bool _isCheckingSave = true;
|
bool _isCheckingSave = true;
|
||||||
bool _hasSave = false;
|
bool _hasSave = false;
|
||||||
SavedGamePreview? _savedGamePreview;
|
SavedGamePreview? _savedGamePreview;
|
||||||
@@ -437,6 +439,7 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
|
|||||||
theme: _lightTheme,
|
theme: _lightTheme,
|
||||||
darkTheme: _darkTheme,
|
darkTheme: _darkTheme,
|
||||||
themeMode: _themeMode,
|
themeMode: _themeMode,
|
||||||
|
navigatorObservers: [_routeObserver],
|
||||||
builder: (context, child) {
|
builder: (context, child) {
|
||||||
// 현재 로케일을 게임 텍스트 l10n 시스템에 동기화
|
// 현재 로케일을 게임 텍스트 l10n 시스템에 동기화
|
||||||
final locale = Localizations.localeOf(context);
|
final locale = Localizations.localeOf(context);
|
||||||
@@ -465,6 +468,11 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
|
|||||||
hasSaveFile: _hasSave,
|
hasSaveFile: _hasSave,
|
||||||
savedGamePreview: _savedGamePreview,
|
savedGamePreview: _savedGamePreview,
|
||||||
hallOfFameCount: _hallOfFame.count,
|
hallOfFameCount: _hallOfFame.count,
|
||||||
|
routeObserver: _routeObserver,
|
||||||
|
onRefresh: () {
|
||||||
|
_checkForExistingSave();
|
||||||
|
_loadHallOfFame();
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -480,8 +488,9 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
.then((_) {
|
.then((_) {
|
||||||
// 새 게임 후 돌아오면 세이브 정보 갱신 (BGM은 _checkForExistingSave에서 재생)
|
// 새 게임 후 돌아오면 세이브 정보 및 명예의 전당 갱신
|
||||||
_checkForExistingSave();
|
_checkForExistingSave();
|
||||||
|
_loadHallOfFame();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -560,8 +569,9 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
.then((_) {
|
.then((_) {
|
||||||
// 게임에서 돌아오면 세이브 정보 갱신 (BGM은 _checkForExistingSave에서 재생)
|
// 게임에서 돌아오면 세이브 정보 및 명예의 전당 갱신
|
||||||
_checkForExistingSave();
|
_checkForExistingSave();
|
||||||
|
_loadHallOfFame();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -574,7 +584,8 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
.then((_) {
|
.then((_) {
|
||||||
// 명예의 전당에서 돌아오면 타이틀 BGM 재생
|
// 명예의 전당에서 돌아오면 명예의 전당 갱신 및 타이틀 BGM 재생
|
||||||
|
_loadHallOfFame();
|
||||||
_audioService.playBgm('title');
|
_audioService.playBgm('title');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,15 +68,17 @@ class CanvasBattleComposer {
|
|||||||
bool isDot = false,
|
bool isDot = false,
|
||||||
bool isBlock = false,
|
bool isBlock = false,
|
||||||
bool isParry = false,
|
bool isParry = false,
|
||||||
|
bool hideMonster = false,
|
||||||
}) {
|
}) {
|
||||||
final layers = <AsciiLayer>[
|
final layers = <AsciiLayer>[
|
||||||
_createBackgroundLayer(environment, globalTick),
|
_createBackgroundLayer(environment, globalTick),
|
||||||
_createCharacterLayer(phase, subFrame, attacker),
|
_createCharacterLayer(phase, subFrame, attacker),
|
||||||
// PvP 모드: 몬스터 대신 상대 캐릭터(좌우 반전) 표시
|
// PvP 모드: 몬스터 대신 상대 캐릭터(좌우 반전) 표시
|
||||||
if (isPvP)
|
// hideMonster: 몬스터 사망 애니메이션 중에는 렌더링 안함
|
||||||
_createOpponentCharacterLayer(phase, subFrame, attacker)
|
if (!hideMonster)
|
||||||
else
|
isPvP
|
||||||
_createMonsterLayer(phase, subFrame, attacker),
|
? _createOpponentCharacterLayer(phase, subFrame, attacker)
|
||||||
|
: _createMonsterLayer(phase, subFrame, attacker),
|
||||||
];
|
];
|
||||||
|
|
||||||
// 이펙트 레이어 (준비/공격/히트 페이즈에서, 공격자 있을 때)
|
// 이펙트 레이어 (준비/공격/히트 페이즈에서, 공격자 있을 때)
|
||||||
@@ -1460,3 +1462,13 @@ const _parryTextFrames = <List<String>>[
|
|||||||
[r'*PARRY!*', r'========'],
|
[r'*PARRY!*', r'========'],
|
||||||
[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 {
|
Future<void> playBgm(String name) async {
|
||||||
if (_isPaused) return;
|
if (_isPaused) return;
|
||||||
if (!_staticInitialized) await init();
|
if (!_staticInitialized) await init();
|
||||||
|
if (_bgmVolume == 0) return; // 볼륨 0이면 재생 안함
|
||||||
if (_currentBgm == name) return;
|
if (_currentBgm == name) return;
|
||||||
if (_staticBgmPlayer == null) return;
|
if (_staticBgmPlayer == null) return;
|
||||||
|
|
||||||
@@ -376,6 +377,7 @@ class AudioService {
|
|||||||
/// 플레이어 이펙트 SFX 재생
|
/// 플레이어 이펙트 SFX 재생
|
||||||
Future<void> playPlayerSfx(String name) async {
|
Future<void> playPlayerSfx(String name) async {
|
||||||
if (_isPaused) return;
|
if (_isPaused) return;
|
||||||
|
if (_sfxVolume == 0) return; // 볼륨 0이면 재생 안함
|
||||||
if (!_staticInitialized) await init();
|
if (!_staticInitialized) await init();
|
||||||
_tryPlayPendingBgm();
|
_tryPlayPendingBgm();
|
||||||
await _playerSfxPool?.play('assets/audio/sfx/$name.mp3');
|
await _playerSfxPool?.play('assets/audio/sfx/$name.mp3');
|
||||||
@@ -384,6 +386,7 @@ class AudioService {
|
|||||||
/// 몬스터 이펙트 SFX 재생
|
/// 몬스터 이펙트 SFX 재생
|
||||||
Future<void> playMonsterSfx(String name) async {
|
Future<void> playMonsterSfx(String name) async {
|
||||||
if (_isPaused) return;
|
if (_isPaused) return;
|
||||||
|
if (_sfxVolume == 0) return; // 볼륨 0이면 재생 안함
|
||||||
if (!_staticInitialized) await init();
|
if (!_staticInitialized) await init();
|
||||||
_tryPlayPendingBgm();
|
_tryPlayPendingBgm();
|
||||||
await _monsterSfxPool?.play('assets/audio/sfx/$name.mp3');
|
await _monsterSfxPool?.play('assets/audio/sfx/$name.mp3');
|
||||||
@@ -412,7 +415,13 @@ class AudioService {
|
|||||||
_bgmVolume = volume.clamp(0.0, 1.0);
|
_bgmVolume = volume.clamp(0.0, 1.0);
|
||||||
if (_staticBgmPlayer != null) {
|
if (_staticBgmPlayer != null) {
|
||||||
try {
|
try {
|
||||||
await _staticBgmPlayer!.setVolume(_bgmVolume);
|
// 볼륨 0이면 BGM 정지
|
||||||
|
if (_bgmVolume == 0) {
|
||||||
|
await _staticBgmPlayer!.stop();
|
||||||
|
_currentBgm = null;
|
||||||
|
} else {
|
||||||
|
await _staticBgmPlayer!.setVolume(_bgmVolume);
|
||||||
|
}
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
await _settingsRepository.saveBgmVolume(_bgmVolume);
|
await _settingsRepository.saveBgmVolume(_bgmVolume);
|
||||||
|
|||||||
@@ -98,9 +98,9 @@ class CombatCalculator {
|
|||||||
final isParried = parryRoll < defenderParryRate;
|
final isParried = parryRoll < defenderParryRate;
|
||||||
|
|
||||||
// 3. 기본 데미지 계산 (0.8 ~ 1.2 변동)
|
// 3. 기본 데미지 계산 (0.8 ~ 1.2 변동)
|
||||||
// DEF 감산 비율: 0.5 (방어력 효과 상향, 몬스터 ATK 하향과 연동)
|
// DEF 감산 비율: 0.4 (전체 데미지 상승 조정)
|
||||||
final damageVariation = 0.8 + rng.nextDouble() * 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. 크리티컬 판정
|
// 4. 크리티컬 판정
|
||||||
final criRoll = rng.nextDouble();
|
final criRoll = rng.nextDouble();
|
||||||
@@ -207,7 +207,7 @@ class CombatCalculator {
|
|||||||
required MonsterCombatStats monster,
|
required MonsterCombatStats monster,
|
||||||
}) {
|
}) {
|
||||||
// 플레이어 DPS (초당 데미지)
|
// 플레이어 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 playerHitsPerSecond = 1000 / player.attackDelayMs;
|
||||||
final playerDps =
|
final playerDps =
|
||||||
playerDamagePerHit * playerHitsPerSecond * player.accuracy;
|
playerDamagePerHit * playerHitsPerSecond * player.accuracy;
|
||||||
@@ -227,14 +227,14 @@ class CombatCalculator {
|
|||||||
required MonsterCombatStats monster,
|
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 monsterHitsPerSecond = 1000 / monster.attackDelayMs;
|
||||||
final monsterDps =
|
final monsterDps =
|
||||||
monsterDamagePerHit * monsterHitsPerSecond * monster.accuracy;
|
monsterDamagePerHit * monsterHitsPerSecond * monster.accuracy;
|
||||||
final playerSurvivalTime = player.hpCurrent / monsterDps;
|
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 playerHitsPerSecond = 1000 / player.attackDelayMs;
|
||||||
final playerDps =
|
final playerDps =
|
||||||
playerDamagePerHit * playerHitsPerSecond * player.accuracy;
|
playerDamagePerHit * playerHitsPerSecond * player.accuracy;
|
||||||
|
|||||||
@@ -588,8 +588,18 @@ class ProgressService {
|
|||||||
|
|
||||||
// 4. 최종 보스 전투 체크
|
// 4. 최종 보스 전투 체크
|
||||||
// finalBossState == fighting이면 Glitch God 스폰
|
// finalBossState == fighting이면 Glitch God 스폰
|
||||||
|
// 단, 레벨링 모드 중이면 일반 몬스터로 레벨업 후 재도전
|
||||||
if (state.progress.finalBossState == FinalBossState.fighting) {
|
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줄)
|
// 5. MonsterTask 실행 (원본 678-684줄)
|
||||||
@@ -634,6 +644,7 @@ class ProgressService {
|
|||||||
name: monsterResult.displayName,
|
name: monsterResult.displayName,
|
||||||
level: effectiveMonsterLevel,
|
level: effectiveMonsterLevel,
|
||||||
speedType: MonsterCombatStats.inferSpeedType(monsterResult.baseName),
|
speedType: MonsterCombatStats.inferSpeedType(monsterResult.baseName),
|
||||||
|
plotStageCount: state.progress.plotStageCount,
|
||||||
);
|
);
|
||||||
|
|
||||||
// 전투 상태 초기화
|
// 전투 상태 초기화
|
||||||
@@ -1667,6 +1678,7 @@ class ProgressService {
|
|||||||
/// 플레이어 사망 처리 (Phase 4)
|
/// 플레이어 사망 처리 (Phase 4)
|
||||||
///
|
///
|
||||||
/// 모든 장비 상실 및 사망 정보 기록
|
/// 모든 장비 상실 및 사망 정보 기록
|
||||||
|
/// 보스전 사망 시: 장비 보호 + 5분 레벨링 모드 진입
|
||||||
GameState _processPlayerDeath(
|
GameState _processPlayerDeath(
|
||||||
GameState state, {
|
GameState state, {
|
||||||
required String killerName,
|
required String killerName,
|
||||||
@@ -1676,30 +1688,36 @@ class ProgressService {
|
|||||||
final lastCombatEvents =
|
final lastCombatEvents =
|
||||||
state.progress.currentCombat?.recentEvents ?? const [];
|
state.progress.currentCombat?.recentEvents ?? const [];
|
||||||
|
|
||||||
// 무기(슬롯 0)를 제외한 장착된 장비 중 1개를 제물로 삭제
|
// 보스전 사망 여부 확인 (최종 보스 fighting 상태)
|
||||||
// 장착된 비무기 슬롯 인덱스 수집 (슬롯 1~10 중 장비가 있는 것)
|
final isBossDeath =
|
||||||
final equippedNonWeaponSlots = <int>[];
|
state.progress.finalBossState == FinalBossState.fighting;
|
||||||
for (var i = 1; i < Equipment.slotCount; i++) {
|
|
||||||
if (state.equipment.getItemByIndex(i).isNotEmpty) {
|
|
||||||
equippedNonWeaponSlots.add(i);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 제물로 바칠 장비 선택 및 삭제
|
// 보스전 사망이 아닐 경우에만 장비 손실
|
||||||
var newEquipment = state.equipment;
|
var newEquipment = state.equipment;
|
||||||
final lostCount = equippedNonWeaponSlots.isNotEmpty ? 1 : 0;
|
var lostCount = 0;
|
||||||
|
|
||||||
if (equippedNonWeaponSlots.isNotEmpty) {
|
if (!isBossDeath) {
|
||||||
// 랜덤하게 1개 슬롯 선택
|
// 무기(슬롯 0)를 제외한 장착된 장비 중 1개를 제물로 삭제
|
||||||
final sacrificeIndex =
|
final equippedNonWeaponSlots = <int>[];
|
||||||
equippedNonWeaponSlots[state.rng.nextInt(equippedNonWeaponSlots.length)];
|
for (var i = 1; i < Equipment.slotCount; i++) {
|
||||||
final slot = EquipmentSlot.values[sacrificeIndex];
|
if (state.equipment.getItemByIndex(i).isNotEmpty) {
|
||||||
|
equippedNonWeaponSlots.add(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 해당 슬롯을 빈 장비로 교체
|
if (equippedNonWeaponSlots.isNotEmpty) {
|
||||||
newEquipment = newEquipment.setItemByIndex(
|
lostCount = 1;
|
||||||
sacrificeIndex,
|
// 랜덤하게 1개 슬롯 선택
|
||||||
EquipmentItem.empty(slot),
|
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,
|
lastCombatEvents: lastCombatEvents,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 보스전 사망 시 5분 레벨링 모드 진입
|
||||||
|
final bossLevelingEndTime = isBossDeath
|
||||||
|
? DateTime.now().millisecondsSinceEpoch + (5 * 60 * 1000) // 5분
|
||||||
|
: null;
|
||||||
|
|
||||||
// 전투 상태 초기화 및 사망 횟수 증가
|
// 전투 상태 초기화 및 사망 횟수 증가
|
||||||
// pendingActCompletion 플래그는 유지 (Boss 리트라이를 위해)
|
|
||||||
final progress = state.progress.copyWith(
|
final progress = state.progress.copyWith(
|
||||||
currentCombat: null,
|
currentCombat: null,
|
||||||
deathCount: state.progress.deathCount + 1,
|
deathCount: state.progress.deathCount + 1,
|
||||||
// pendingActCompletion은 copyWith에서 명시하지 않으면 기존 값 유지
|
bossLevelingEndTime: bossLevelingEndTime,
|
||||||
);
|
);
|
||||||
|
|
||||||
return state.copyWith(
|
return state.copyWith(
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:asciineverdie/data/game_text_l10n.dart' as l10n;
|
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/data/game_translations_ko.dart';
|
||||||
import 'package:asciineverdie/src/core/util/pq_logic.dart';
|
import 'package:asciineverdie/src/core/util/pq_logic.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
@@ -18,6 +19,13 @@ class GameDataL10n {
|
|||||||
return locale.languageCode == 'ko';
|
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) {
|
static String getRaceName(BuildContext context, String englishName) {
|
||||||
if (_isKorean(context)) {
|
if (_isKorean(context)) {
|
||||||
@@ -284,7 +292,9 @@ class GameDataL10n {
|
|||||||
String equipString,
|
String equipString,
|
||||||
int slotIndex,
|
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. +/- 값 추출
|
// 1. +/- 값 추출
|
||||||
final plusMatch = RegExp(r'^([+-]?\d+)\s+').firstMatch(equipString);
|
final plusMatch = RegExp(r'^([+-]?\d+)\s+').firstMatch(equipString);
|
||||||
@@ -296,13 +306,24 @@ class GameDataL10n {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2. 기본 장비 이름 찾기 (가장 긴 매칭 우선)
|
// 2. 기본 장비 이름 찾기 (가장 긴 매칭 우선)
|
||||||
|
// 통합 맵 사용 (추가 번역 포함)
|
||||||
final Map<String, String> baseMap;
|
final Map<String, String> baseMap;
|
||||||
if (slotIndex == 0) {
|
if (isKo) {
|
||||||
baseMap = weaponTranslationsKo;
|
if (slotIndex == 0) {
|
||||||
} else if (slotIndex == 1) {
|
baseMap = weaponTranslationsKo;
|
||||||
baseMap = shieldTranslationsKo;
|
} else if (slotIndex == 1) {
|
||||||
|
baseMap = allShieldTranslationsKo;
|
||||||
|
} else {
|
||||||
|
baseMap = allArmorTranslationsKo;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
baseMap = armorTranslationsKo;
|
if (slotIndex == 0) {
|
||||||
|
baseMap = weaponTranslationsJa;
|
||||||
|
} else if (slotIndex == 1) {
|
||||||
|
baseMap = allShieldTranslationsJa;
|
||||||
|
} else {
|
||||||
|
baseMap = allArmorTranslationsJa;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String baseTranslated = remaining;
|
String baseTranslated = remaining;
|
||||||
@@ -326,14 +347,26 @@ class GameDataL10n {
|
|||||||
.where((s) => s.isNotEmpty)
|
.where((s) => s.isNotEmpty)
|
||||||
.toList();
|
.toList();
|
||||||
final translatedMods = modWords.map((mod) {
|
final translatedMods = modWords.map((mod) {
|
||||||
if (isWeapon) {
|
if (isKo) {
|
||||||
return offenseAttribTranslationsKo[mod] ??
|
if (isWeapon) {
|
||||||
offenseBadTranslationsKo[mod] ??
|
return offenseAttribTranslationsKo[mod] ??
|
||||||
mod;
|
offenseBadTranslationsKo[mod] ??
|
||||||
|
mod;
|
||||||
|
} else {
|
||||||
|
return defenseAttribTranslationsKo[mod] ??
|
||||||
|
defenseBadTranslationsKo[mod] ??
|
||||||
|
mod;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
return defenseAttribTranslationsKo[mod] ??
|
if (isWeapon) {
|
||||||
defenseBadTranslationsKo[mod] ??
|
return offenseAttribTranslationsJa[mod] ??
|
||||||
mod;
|
offenseBadTranslationsJa[mod] ??
|
||||||
|
mod;
|
||||||
|
} else {
|
||||||
|
return defenseAttribTranslationsJa[mod] ??
|
||||||
|
defenseBadTranslationsJa[mod] ??
|
||||||
|
mod;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}).toList();
|
}).toList();
|
||||||
|
|
||||||
@@ -353,15 +386,17 @@ class GameDataL10n {
|
|||||||
/// 예: "Golden Iterator of Compilation" → "컴파일의 황금 이터레이터"
|
/// 예: "Golden Iterator of Compilation" → "컴파일의 황금 이터레이터"
|
||||||
/// 예: "index out of bounds Array fragment" → "인덱스 초과의 배열 조각"
|
/// 예: "index out of bounds Array fragment" → "인덱스 초과의 배열 조각"
|
||||||
static String translateItemString(BuildContext context, String itemString) {
|
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"
|
// 1. specialItem 형식 체크: "Attrib Special of ItemOf"
|
||||||
// itemOfs에 있는 값으로 끝나는지 확인
|
// itemOfs에 있는 값으로 끝나는지 확인
|
||||||
final specialItemResult = _tryTranslateSpecialItem(itemString);
|
final specialItemResult = _tryTranslateSpecialItem(itemString, isKo);
|
||||||
if (specialItemResult != null) return specialItemResult;
|
if (specialItemResult != null) return specialItemResult;
|
||||||
|
|
||||||
// 2. 몬스터 드롭 형식 체크: "{monster_lowercase} {drop_ProperCase}"
|
// 2. 몬스터 드롭 형식 체크: "{monster_lowercase} {drop_ProperCase}"
|
||||||
final monsterDropResult = _tryTranslateMonsterDrop(itemString);
|
final monsterDropResult = _tryTranslateMonsterDrop(itemString, isKo);
|
||||||
if (monsterDropResult != null) return monsterDropResult;
|
if (monsterDropResult != null) return monsterDropResult;
|
||||||
|
|
||||||
// 3. interestingItem 형식: "Attrib Special" (2단어)
|
// 3. interestingItem 형식: "Attrib Special" (2단어)
|
||||||
@@ -370,21 +405,32 @@ class GameDataL10n {
|
|||||||
final attrib = words[0];
|
final attrib = words[0];
|
||||||
final special = words[1];
|
final special = words[1];
|
||||||
|
|
||||||
final attribKo = itemAttribTranslationsKo[attrib] ?? attrib;
|
if (isKo) {
|
||||||
final specialKo = specialTranslationsKo[special] ?? special;
|
final attribKo = itemAttribTranslationsKo[attrib] ?? attrib;
|
||||||
|
final specialKo = specialTranslationsKo[special] ?? special;
|
||||||
return '$attribKo $specialKo';
|
return '$attribKo $specialKo';
|
||||||
|
} else {
|
||||||
|
final attribJa = itemAttribTranslationsJa[attrib] ?? attrib;
|
||||||
|
final specialJa = specialTranslationsJa[special] ?? special;
|
||||||
|
return '$attribJa $specialJa';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 단일 단어 (boringItem 등) - 잡템 번역 시도
|
// 4. 단일 단어 (boringItem 등) - 잡템 번역 시도
|
||||||
return boringItemTranslationsKo[itemString] ??
|
if (isKo) {
|
||||||
dropItemTranslationsKo[itemString.toLowerCase()] ??
|
return boringItemTranslationsKo[itemString] ??
|
||||||
itemString;
|
allDropTranslationsKo[itemString.toLowerCase()] ??
|
||||||
|
itemString;
|
||||||
|
} else {
|
||||||
|
return boringItemTranslationsJa[itemString] ??
|
||||||
|
allDropTranslationsJa[itemString.toLowerCase()] ??
|
||||||
|
itemString;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// specialItem 형식 번역 시도
|
/// specialItem 형식 번역 시도
|
||||||
/// "Attrib Special of ItemOf" → "ItemOf의 Attrib Special"
|
/// "Attrib Special of ItemOf" → "ItemOf의 Attrib Special"
|
||||||
static String? _tryTranslateSpecialItem(String itemString) {
|
static String? _tryTranslateSpecialItem(String itemString, bool isKo) {
|
||||||
// "of" 뒤의 부분이 itemOfs에 있는지 확인
|
// "of" 뒤의 부분이 itemOfs에 있는지 확인
|
||||||
final ofMatch = RegExp(r'^(.+)\s+of\s+(.+)$').firstMatch(itemString);
|
final ofMatch = RegExp(r'^(.+)\s+of\s+(.+)$').firstMatch(itemString);
|
||||||
if (ofMatch == null) return null;
|
if (ofMatch == null) return null;
|
||||||
@@ -393,7 +439,8 @@ class GameDataL10n {
|
|||||||
final afterOf = ofMatch.group(2)!;
|
final afterOf = ofMatch.group(2)!;
|
||||||
|
|
||||||
// afterOf가 itemOfs에 있어야 specialItem 형식
|
// afterOf가 itemOfs에 있어야 specialItem 형식
|
||||||
if (!itemOfsTranslationsKo.containsKey(afterOf)) return null;
|
final itemOfsMap = isKo ? itemOfsTranslationsKo : itemOfsTranslationsJa;
|
||||||
|
if (!itemOfsMap.containsKey(afterOf)) return null;
|
||||||
|
|
||||||
// beforeOf를 Attrib + Special로 분리
|
// beforeOf를 Attrib + Special로 분리
|
||||||
final words = beforeOf.split(' ');
|
final words = beforeOf.split(' ');
|
||||||
@@ -403,24 +450,32 @@ class GameDataL10n {
|
|||||||
final special = words.last;
|
final special = words.last;
|
||||||
|
|
||||||
// Attrib와 Special이 유효한지 확인
|
// Attrib와 Special이 유효한지 확인
|
||||||
if (!itemAttribTranslationsKo.containsKey(attrib) &&
|
final attribMap = isKo ? itemAttribTranslationsKo : itemAttribTranslationsJa;
|
||||||
!specialTranslationsKo.containsKey(special)) {
|
final specialMap = isKo ? specialTranslationsKo : specialTranslationsJa;
|
||||||
|
if (!attribMap.containsKey(attrib) && !specialMap.containsKey(special)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
final attribKo = itemAttribTranslationsKo[attrib] ?? attrib;
|
final attribT = attribMap[attrib] ?? attrib;
|
||||||
final specialKo = specialTranslationsKo[special] ?? special;
|
final specialT = specialMap[special] ?? special;
|
||||||
final itemOfKo = itemOfsTranslationsKo[afterOf] ?? afterOf;
|
final itemOfT = itemOfsMap[afterOf] ?? afterOf;
|
||||||
|
|
||||||
return '$itemOfKo의 $attribKo $specialKo';
|
if (isKo) {
|
||||||
|
return '$itemOfT의 $attribT $specialT';
|
||||||
|
} else {
|
||||||
|
return '$itemOfTの$attribT $specialT';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 몬스터 드롭 형식 번역 시도
|
/// 몬스터 드롭 형식 번역 시도
|
||||||
/// "{monster_lowercase} {drop_ProperCase}" → "{몬스터}의 {드롭아이템}"
|
/// "{monster_lowercase} {drop_ProperCase}" → "{몬스터}의 {드롭아이템}"
|
||||||
static String? _tryTranslateMonsterDrop(String itemString) {
|
static String? _tryTranslateMonsterDrop(String itemString, bool isKo) {
|
||||||
// dropItemTranslationsKo에서 매칭되는 드롭 아이템 찾기
|
// 드롭 아이템 번역 맵 선택 (통합 맵 사용)
|
||||||
|
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 dropItem = entry.key;
|
||||||
final dropItemProperCase = _properCase(dropItem);
|
final dropItemProperCase = _properCase(dropItem);
|
||||||
|
|
||||||
@@ -443,10 +498,14 @@ class GameDataL10n {
|
|||||||
|
|
||||||
// 몬스터 이름 번역 (소문자를 원래 형태로 변환하여 찾기)
|
// 몬스터 이름 번역 (소문자를 원래 형태로 변환하여 찾기)
|
||||||
final monsterNameKey = _toTitleCase(monsterPart);
|
final monsterNameKey = _toTitleCase(monsterPart);
|
||||||
final monsterKo = monsterTranslationsKo[monsterNameKey] ?? monsterPart;
|
final monsterT = monsterMap[monsterNameKey] ?? monsterPart;
|
||||||
|
|
||||||
final dropKo = entry.value;
|
final dropT = entry.value;
|
||||||
return '$monsterKo의 $dropKo';
|
if (isKo) {
|
||||||
|
return '$monsterT의 $dropT';
|
||||||
|
} else {
|
||||||
|
return '$monsterTの$dropT';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -475,7 +475,8 @@ class Inventory {
|
|||||||
final int gold;
|
final int gold;
|
||||||
final List<InventoryEntry> items;
|
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}) {
|
Inventory copyWith({int? gold, List<InventoryEntry>? items}) {
|
||||||
return Inventory(gold: gold ?? this.gold, items: items ?? this.items);
|
return Inventory(gold: gold ?? this.gold, items: items ?? this.items);
|
||||||
@@ -800,6 +801,7 @@ class ProgressState {
|
|||||||
this.deathCount = 0,
|
this.deathCount = 0,
|
||||||
this.finalBossState = FinalBossState.notSpawned,
|
this.finalBossState = FinalBossState.notSpawned,
|
||||||
this.pendingActCompletion = false,
|
this.pendingActCompletion = false,
|
||||||
|
this.bossLevelingEndTime,
|
||||||
});
|
});
|
||||||
|
|
||||||
final ProgressBarState task;
|
final ProgressBarState task;
|
||||||
@@ -835,6 +837,10 @@ class ProgressState {
|
|||||||
/// Act Boss 처치 대기 중 여부 (처치 후 시네마틱 재생 트리거)
|
/// Act Boss 처치 대기 중 여부 (처치 후 시네마틱 재생 트리거)
|
||||||
final bool pendingActCompletion;
|
final bool pendingActCompletion;
|
||||||
|
|
||||||
|
/// 보스 사망 후 레벨링 모드 종료 시간 (milliseconds since epoch)
|
||||||
|
/// null이면 레벨링 모드 아님, 값이 있으면 해당 시간까지 레벨링
|
||||||
|
final int? bossLevelingEndTime;
|
||||||
|
|
||||||
factory ProgressState.empty() => ProgressState(
|
factory ProgressState.empty() => ProgressState(
|
||||||
task: ProgressBarState.empty(),
|
task: ProgressBarState.empty(),
|
||||||
quest: ProgressBarState.empty(),
|
quest: ProgressBarState.empty(),
|
||||||
@@ -867,6 +873,8 @@ class ProgressState {
|
|||||||
int? deathCount,
|
int? deathCount,
|
||||||
FinalBossState? finalBossState,
|
FinalBossState? finalBossState,
|
||||||
bool? pendingActCompletion,
|
bool? pendingActCompletion,
|
||||||
|
int? bossLevelingEndTime,
|
||||||
|
bool clearBossLevelingEndTime = false,
|
||||||
}) {
|
}) {
|
||||||
return ProgressState(
|
return ProgressState(
|
||||||
task: task ?? this.task,
|
task: task ?? this.task,
|
||||||
@@ -885,8 +893,17 @@ class ProgressState {
|
|||||||
deathCount: deathCount ?? this.deathCount,
|
deathCount: deathCount ?? this.deathCount,
|
||||||
finalBossState: finalBossState ?? this.finalBossState,
|
finalBossState: finalBossState ?? this.finalBossState,
|
||||||
pendingActCompletion: pendingActCompletion ?? this.pendingActCompletion,
|
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 {
|
class QueueEntry {
|
||||||
|
|||||||
@@ -131,15 +131,29 @@ class MonsterCombatStats {
|
|||||||
/// [level] 몬스터 레벨 (원본 데이터 기준)
|
/// [level] 몬스터 레벨 (원본 데이터 기준)
|
||||||
/// [speedType] 공격 속도 타입 (기본: normal)
|
/// [speedType] 공격 속도 타입 (기본: normal)
|
||||||
/// [monsterType] 몬스터 타입 (기본: normal)
|
/// [monsterType] 몬스터 타입 (기본: normal)
|
||||||
|
/// [plotStageCount] 현재 Act (1=Prologue, 2=Act I, 3=Act II, ...)
|
||||||
factory MonsterCombatStats.fromLevel({
|
factory MonsterCombatStats.fromLevel({
|
||||||
required String name,
|
required String name,
|
||||||
required int level,
|
required int level,
|
||||||
MonsterSpeedType speedType = MonsterSpeedType.normal,
|
MonsterSpeedType speedType = MonsterSpeedType.normal,
|
||||||
MonsterType monsterType = MonsterType.normal,
|
MonsterType monsterType = MonsterType.normal,
|
||||||
|
int plotStageCount = 1,
|
||||||
}) {
|
}) {
|
||||||
// balance_constants.dart의 MonsterBaseStats 사용
|
// balance_constants.dart의 MonsterBaseStats 사용
|
||||||
final baseStats = MonsterBaseStats.generate(level, monsterType);
|
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)
|
// 크리티컬 확률: 레벨에 따라 천천히 증가 (0.02 ~ 0.3)
|
||||||
final criRate = (0.02 + level * 0.003).clamp(0.02, 0.3);
|
final criRate = (0.02 + level * 0.003).clamp(0.02, 0.3);
|
||||||
|
|
||||||
@@ -164,14 +178,14 @@ class MonsterCombatStats {
|
|||||||
level: level,
|
level: level,
|
||||||
atk: baseStats.atk,
|
atk: baseStats.atk,
|
||||||
def: baseStats.def,
|
def: baseStats.def,
|
||||||
hpMax: baseStats.hp,
|
hpMax: adjustedHp,
|
||||||
hpCurrent: baseStats.hp,
|
hpCurrent: adjustedHp,
|
||||||
criRate: criRate,
|
criRate: criRate,
|
||||||
criDamage: criDamage,
|
criDamage: criDamage,
|
||||||
evasion: evasion,
|
evasion: evasion,
|
||||||
accuracy: accuracy,
|
accuracy: accuracy,
|
||||||
attackDelayMs: attackDelayMs,
|
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/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_combat_log.dart';
|
||||||
import 'package:asciineverdie/src/features/arena/widgets/arena_result_panel.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/ascii_animation_card.dart';
|
||||||
import 'package:asciineverdie/src/features/game/widgets/combat_log.dart';
|
import 'package:asciineverdie/src/features/game/widgets/combat_log.dart';
|
||||||
import 'package:asciineverdie/src/shared/retro_colors.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/retro_colors.dart';
|
||||||
import 'package:asciineverdie/src/shared/widgets/retro_widgets.dart';
|
import 'package:asciineverdie/src/shared/widgets/retro_widgets.dart';
|
||||||
|
|
||||||
class FrontScreen extends StatelessWidget {
|
class FrontScreen extends StatefulWidget {
|
||||||
const FrontScreen({
|
const FrontScreen({
|
||||||
super.key,
|
super.key,
|
||||||
this.onNewCharacter,
|
this.onNewCharacter,
|
||||||
@@ -20,6 +20,8 @@ class FrontScreen extends StatelessWidget {
|
|||||||
this.hasSaveFile = false,
|
this.hasSaveFile = false,
|
||||||
this.savedGamePreview,
|
this.savedGamePreview,
|
||||||
this.hallOfFameCount = 0,
|
this.hallOfFameCount = 0,
|
||||||
|
this.routeObserver,
|
||||||
|
this.onRefresh,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// "New character" 버튼 클릭 시 호출
|
/// "New character" 버튼 클릭 시 호출
|
||||||
@@ -43,12 +45,45 @@ class FrontScreen extends StatelessWidget {
|
|||||||
/// 명예의 전당 캐릭터 수 (아레나 활성화 조건: 2명 이상)
|
/// 명예의 전당 캐릭터 수 (아레나 활성화 조건: 2명 이상)
|
||||||
final int hallOfFameCount;
|
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) {
|
void _handleNewCharacter(BuildContext context) {
|
||||||
if (hasSaveFile) {
|
if (widget.hasSaveFile) {
|
||||||
_showDeleteWarningDialog(context);
|
_showDeleteWarningDialog(context);
|
||||||
} else {
|
} else {
|
||||||
onNewCharacter?.call(context);
|
widget.onNewCharacter?.call(context);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,7 +102,7 @@ class FrontScreen extends StatelessWidget {
|
|||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
Navigator.pop(dialogContext);
|
Navigator.pop(dialogContext);
|
||||||
onNewCharacter?.call(context);
|
widget.onNewCharacter?.call(context);
|
||||||
},
|
},
|
||||||
child: Text(game_l10n.buttonConfirm),
|
child: Text(game_l10n.buttonConfirm),
|
||||||
),
|
),
|
||||||
@@ -98,21 +133,21 @@ class FrontScreen extends StatelessWidget {
|
|||||||
const _AnimationPanel(),
|
const _AnimationPanel(),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_ActionButtons(
|
_ActionButtons(
|
||||||
onNewCharacter: onNewCharacter != null
|
onNewCharacter: widget.onNewCharacter != null
|
||||||
? () => _handleNewCharacter(context)
|
? () => _handleNewCharacter(context)
|
||||||
: null,
|
: null,
|
||||||
onLoadSave: onLoadSave != null
|
onLoadSave: widget.onLoadSave != null
|
||||||
? () => onLoadSave!(context)
|
? () => widget.onLoadSave!(context)
|
||||||
: null,
|
: null,
|
||||||
onHallOfFame: onHallOfFame != null
|
onHallOfFame: widget.onHallOfFame != null
|
||||||
? () => onHallOfFame!(context)
|
? () => widget.onHallOfFame!(context)
|
||||||
: null,
|
: null,
|
||||||
onLocalArena:
|
onLocalArena: widget.onLocalArena != null &&
|
||||||
onLocalArena != null && hallOfFameCount >= 2
|
widget.hallOfFameCount >= 2
|
||||||
? () => onLocalArena!(context)
|
? () => widget.onLocalArena!(context)
|
||||||
: null,
|
: null,
|
||||||
savedGamePreview: savedGamePreview,
|
savedGamePreview: widget.savedGamePreview,
|
||||||
hallOfFameCount: hallOfFameCount,
|
hallOfFameCount: widget.hallOfFameCount,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -262,16 +297,14 @@ class _ActionButtons extends StatelessWidget {
|
|||||||
onPressed: onHallOfFame,
|
onPressed: onHallOfFame,
|
||||||
isPrimary: false,
|
isPrimary: false,
|
||||||
),
|
),
|
||||||
// 로컬 아레나 (2명 이상일 때만 활성화)
|
// 로컬 아레나 (항상 표시, 2명 이상일 때만 활성화)
|
||||||
if (hallOfFameCount >= 2) ...[
|
const SizedBox(height: 12),
|
||||||
const SizedBox(height: 12),
|
RetroTextButton(
|
||||||
RetroTextButton(
|
text: game_l10n.uiLocalArena,
|
||||||
text: game_l10n.uiLocalArena,
|
icon: Icons.sports_kabaddi,
|
||||||
icon: Icons.sports_kabaddi,
|
onPressed: hallOfFameCount >= 2 ? onLocalArena : null,
|
||||||
onPressed: onLocalArena,
|
isPrimary: false,
|
||||||
isPrimary: false,
|
),
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1181,6 +1181,7 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
|||||||
state.progress.currentCombat?.monsterStats.hpCurrent,
|
state.progress.currentCombat?.monsterStats.hpCurrent,
|
||||||
monsterHpMax: state.progress.currentCombat?.monsterStats.hpMax,
|
monsterHpMax: state.progress.currentCombat?.monsterStats.hpMax,
|
||||||
monsterName: state.progress.currentCombat?.monsterStats.name,
|
monsterName: state.progress.currentCombat?.monsterStats.name,
|
||||||
|
monsterLevel: state.progress.currentCombat?.monsterStats.level,
|
||||||
),
|
),
|
||||||
|
|
||||||
// Experience 바
|
// Experience 바
|
||||||
|
|||||||
@@ -107,6 +107,14 @@ class GameSessionController extends ChangeNotifier {
|
|||||||
if (isNewGame) {
|
if (isNewGame) {
|
||||||
_sessionStats = SessionStatistics.empty();
|
_sessionStats = SessionStatistics.empty();
|
||||||
await _statisticsStorage.recordGameStart();
|
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);
|
_initPreviousValues(state);
|
||||||
|
|
||||||
@@ -341,7 +349,7 @@ class GameSessionController extends ChangeNotifier {
|
|||||||
|
|
||||||
final entry = HallOfFameEntry.fromGameState(
|
final entry = HallOfFameEntry.fromGameState(
|
||||||
state: _state!,
|
state: _state!,
|
||||||
totalDeaths: _sessionStats.deathCount,
|
totalDeaths: _state!.progress.deathCount, // GameState에 저장된 값 사용
|
||||||
monstersKilled: _state!.progress.monstersKilled,
|
monstersKilled: _state!.progress.monstersKilled,
|
||||||
combatStats: combatStats,
|
combatStats: combatStats,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'dart:async';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import 'package:asciineverdie/src/core/animation/ascii_animation_data.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/ascii_animation_type.dart';
|
||||||
import 'package:asciineverdie/src/core/animation/background_layer.dart';
|
import 'package:asciineverdie/src/core/animation/background_layer.dart';
|
||||||
import 'package:asciineverdie/src/core/animation/canvas/ascii_canvas_widget.dart';
|
import 'package:asciineverdie/src/core/animation/canvas/ascii_canvas_widget.dart';
|
||||||
@@ -47,6 +48,8 @@ class AsciiAnimationCard extends StatefulWidget {
|
|||||||
this.monsterLevel,
|
this.monsterLevel,
|
||||||
this.monsterGrade,
|
this.monsterGrade,
|
||||||
this.isPaused = false,
|
this.isPaused = false,
|
||||||
|
this.isInCombat = true,
|
||||||
|
this.monsterDied = false,
|
||||||
this.latestCombatEvent,
|
this.latestCombatEvent,
|
||||||
this.raceId,
|
this.raceId,
|
||||||
this.weaponRarity,
|
this.weaponRarity,
|
||||||
@@ -59,6 +62,12 @@ class AsciiAnimationCard extends StatefulWidget {
|
|||||||
/// 일시정지 상태 (true면 애니메이션 정지)
|
/// 일시정지 상태 (true면 애니메이션 정지)
|
||||||
final bool isPaused;
|
final bool isPaused;
|
||||||
|
|
||||||
|
/// 전투 활성 상태 (false면 kill 태스크여도 walking 애니메이션)
|
||||||
|
final bool isInCombat;
|
||||||
|
|
||||||
|
/// 몬스터 사망 여부 (true면 분해 애니메이션 재생)
|
||||||
|
final bool monsterDied;
|
||||||
|
|
||||||
/// 전투 중인 몬스터 기본 이름 (kill 타입일 때만 사용)
|
/// 전투 중인 몬스터 기본 이름 (kill 타입일 때만 사용)
|
||||||
final String? monsterBaseName;
|
final String? monsterBaseName;
|
||||||
final AsciiColorTheme colorTheme;
|
final AsciiColorTheme colorTheme;
|
||||||
@@ -162,6 +171,11 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
|
|||||||
// 특수 애니메이션 프레임 수는 ascii_animation_type.dart의
|
// 특수 애니메이션 프레임 수는 ascii_animation_type.dart의
|
||||||
// specialAnimationFrameCounts 상수 사용
|
// specialAnimationFrameCounts 상수 사용
|
||||||
|
|
||||||
|
// 몬스터 사망 분해 애니메이션 상태
|
||||||
|
bool _showDeathAnimation = false;
|
||||||
|
List<String>? _deathAnimationMonsterLines;
|
||||||
|
String? _lastMonsterBaseName;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -204,12 +218,32 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 몬스터 사망 애니메이션 트리거
|
||||||
|
if (!oldWidget.monsterDied && widget.monsterDied && !_showDeathAnimation) {
|
||||||
|
// 현재 몬스터 프레임 캡처 (분해 애니메이션용)
|
||||||
|
_deathAnimationMonsterLines = _captureMonsterFrame();
|
||||||
|
if (_deathAnimationMonsterLines != null) {
|
||||||
|
setState(() {
|
||||||
|
_showDeathAnimation = true;
|
||||||
|
});
|
||||||
|
return; // 사망 애니메이션 중에는 다른 업데이트 무시
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 사망 애니메이션 중에는 다른 업데이트 무시
|
||||||
|
if (_showDeathAnimation) return;
|
||||||
|
|
||||||
// 전투 이벤트 동기화 (Phase 5)
|
// 전투 이벤트 동기화 (Phase 5)
|
||||||
if (widget.latestCombatEvent != null &&
|
if (widget.latestCombatEvent != null &&
|
||||||
widget.latestCombatEvent!.timestamp != _lastEventTimestamp) {
|
widget.latestCombatEvent!.timestamp != _lastEventTimestamp) {
|
||||||
_handleCombatEvent(widget.latestCombatEvent!);
|
_handleCombatEvent(widget.latestCombatEvent!);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 몬스터 이름 저장 (사망 시 프레임 캡처용)
|
||||||
|
if (widget.monsterBaseName != null) {
|
||||||
|
_lastMonsterBaseName = widget.monsterBaseName;
|
||||||
|
}
|
||||||
|
|
||||||
if (oldWidget.taskType != widget.taskType ||
|
if (oldWidget.taskType != widget.taskType ||
|
||||||
oldWidget.monsterBaseName != widget.monsterBaseName ||
|
oldWidget.monsterBaseName != widget.monsterBaseName ||
|
||||||
oldWidget.weaponName != widget.weaponName ||
|
oldWidget.weaponName != widget.weaponName ||
|
||||||
@@ -218,7 +252,8 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
|
|||||||
oldWidget.raceId != widget.raceId ||
|
oldWidget.raceId != widget.raceId ||
|
||||||
oldWidget.weaponRarity != widget.weaponRarity ||
|
oldWidget.weaponRarity != widget.weaponRarity ||
|
||||||
oldWidget.opponentRaceId != widget.opponentRaceId ||
|
oldWidget.opponentRaceId != widget.opponentRaceId ||
|
||||||
oldWidget.opponentHasShield != widget.opponentHasShield) {
|
oldWidget.opponentHasShield != widget.opponentHasShield ||
|
||||||
|
oldWidget.isInCombat != widget.isInCombat) {
|
||||||
_updateAnimation();
|
_updateAnimation();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -505,12 +540,18 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
|
|||||||
|
|
||||||
switch (animationType) {
|
switch (animationType) {
|
||||||
case AsciiAnimationType.battle:
|
case AsciiAnimationType.battle:
|
||||||
_animationMode = AnimationMode.battle;
|
// 전투 비활성 상태면 walking 모드로 전환 (몬스터 처치 후 이동 중)
|
||||||
_setupBattleComposer();
|
if (!widget.isInCombat) {
|
||||||
_battlePhase = BattlePhase.idle;
|
_animationMode = AnimationMode.walking;
|
||||||
_battleSubFrame = 0;
|
_walkingComposer = CanvasWalkingComposer(raceId: widget.raceId);
|
||||||
_phaseIndex = 0;
|
} else {
|
||||||
_phaseFrameCount = 0;
|
_animationMode = AnimationMode.battle;
|
||||||
|
_setupBattleComposer();
|
||||||
|
_battlePhase = BattlePhase.idle;
|
||||||
|
_battleSubFrame = 0;
|
||||||
|
_phaseIndex = 0;
|
||||||
|
_phaseFrameCount = 0;
|
||||||
|
}
|
||||||
|
|
||||||
case AsciiAnimationType.town:
|
case AsciiAnimationType.town:
|
||||||
_animationMode = AnimationMode.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() {
|
void _advanceBattleFrame() {
|
||||||
_phaseFrameCount++;
|
_phaseFrameCount++;
|
||||||
final currentPhase = _battlePhaseSequence[_phaseIndex];
|
final currentPhase = _battlePhaseSequence[_phaseIndex];
|
||||||
@@ -617,6 +683,7 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
|
|||||||
isDot: _showDotEffect,
|
isDot: _showDotEffect,
|
||||||
isBlock: _showBlockEffect,
|
isBlock: _showBlockEffect,
|
||||||
isParry: _showParryEffect,
|
isParry: _showParryEffect,
|
||||||
|
hideMonster: _showDeathAnimation,
|
||||||
) ??
|
) ??
|
||||||
[AsciiLayer.empty()],
|
[AsciiLayer.empty()],
|
||||||
AnimationMode.walking =>
|
AnimationMode.walking =>
|
||||||
@@ -676,7 +743,26 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
|
|||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(4),
|
||||||
border: borderEffect,
|
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 _lastMp = 0;
|
||||||
int _lastMonsterHp = 0;
|
int _lastMonsterHp = 0;
|
||||||
|
|
||||||
|
// 몬스터 사망 상태 추적
|
||||||
|
bool _monsterDied = false;
|
||||||
|
bool _wasInCombat = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -145,6 +149,27 @@ class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
|
|||||||
} else if (newMonsterHp == null) {
|
} else if (newMonsterHp == null) {
|
||||||
_lastMonsterHp = 0;
|
_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 =>
|
int get _currentHp =>
|
||||||
@@ -197,6 +222,8 @@ class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
|
|||||||
monsterLevel: widget.monsterLevel,
|
monsterLevel: widget.monsterLevel,
|
||||||
monsterGrade: widget.monsterGrade,
|
monsterGrade: widget.monsterGrade,
|
||||||
isPaused: widget.isPaused,
|
isPaused: widget.isPaused,
|
||||||
|
isInCombat: isInCombat,
|
||||||
|
monsterDied: _monsterDied,
|
||||||
latestCombatEvent: widget.latestCombatEvent,
|
latestCombatEvent: widget.latestCombatEvent,
|
||||||
raceId: widget.raceId,
|
raceId: widget.raceId,
|
||||||
weaponRarity: widget.weaponRarity,
|
weaponRarity: widget.weaponRarity,
|
||||||
@@ -480,10 +507,14 @@ class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 몬스터 HP 바 (전투 중)
|
/// 몬스터 HP 바 (전투 중)
|
||||||
|
/// - HP바 중앙에 HP% 오버레이
|
||||||
|
/// - 하단에 레벨.이름 표시
|
||||||
Widget _buildMonsterHpBar(CombatState combat) {
|
Widget _buildMonsterHpBar(CombatState combat) {
|
||||||
final max = _currentMonsterHpMax ?? 1;
|
final max = _currentMonsterHpMax ?? 1;
|
||||||
final current = _currentMonsterHp ?? 0;
|
final current = _currentMonsterHp ?? 0;
|
||||||
final ratio = max > 0 ? current / max : 0.0;
|
final ratio = max > 0 ? current / max : 0.0;
|
||||||
|
final monsterName = combat.monsterStats.name;
|
||||||
|
final monsterLevel = widget.monsterLevel ?? combat.monsterStats.level;
|
||||||
|
|
||||||
return AnimatedBuilder(
|
return AnimatedBuilder(
|
||||||
animation: _monsterFlashAnimation,
|
animation: _monsterFlashAnimation,
|
||||||
@@ -492,7 +523,7 @@ class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
|
|||||||
clipBehavior: Clip.none,
|
clipBehavior: Clip.none,
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
height: 32,
|
height: 36,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.orange.withValues(alpha: 0.1),
|
color: Colors.orange.withValues(alpha: 0.1),
|
||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(4),
|
||||||
@@ -501,27 +532,58 @@ class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
// HP 바
|
// HP 바 (HP% 중앙 오버레이)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||||
child: ClipRRect(
|
child: Stack(
|
||||||
borderRadius: BorderRadius.circular(2),
|
alignment: Alignment.center,
|
||||||
child: LinearProgressIndicator(
|
children: [
|
||||||
value: ratio.clamp(0.0, 1.0),
|
// HP 바
|
||||||
backgroundColor: Colors.orange.withValues(alpha: 0.2),
|
ClipRRect(
|
||||||
valueColor: const AlwaysStoppedAnimation(Colors.orange),
|
borderRadius: BorderRadius.circular(2),
|
||||||
minHeight: 8,
|
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),
|
const SizedBox(height: 2),
|
||||||
// 퍼센트
|
// 레벨.이름 표시
|
||||||
Text(
|
Padding(
|
||||||
'${(ratio * 100).toInt()}%',
|
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||||
style: const TextStyle(
|
child: Text(
|
||||||
fontSize: 9,
|
'Lv.$monsterLevel $monsterName',
|
||||||
color: Colors.orange,
|
style: const TextStyle(
|
||||||
fontWeight: FontWeight.bold,
|
fontSize: 9,
|
||||||
|
color: Colors.orange,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
maxLines: 1,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ class HpMpBar extends StatefulWidget {
|
|||||||
this.monsterHpCurrent,
|
this.monsterHpCurrent,
|
||||||
this.monsterHpMax,
|
this.monsterHpMax,
|
||||||
this.monsterName,
|
this.monsterName,
|
||||||
|
this.monsterLevel,
|
||||||
});
|
});
|
||||||
|
|
||||||
final int hpCurrent;
|
final int hpCurrent;
|
||||||
@@ -30,6 +31,7 @@ class HpMpBar extends StatefulWidget {
|
|||||||
final int? monsterHpCurrent;
|
final int? monsterHpCurrent;
|
||||||
final int? monsterHpMax;
|
final int? monsterHpMax;
|
||||||
final String? monsterName;
|
final String? monsterName;
|
||||||
|
final int? monsterLevel;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<HpMpBar> createState() => _HpMpBarState();
|
State<HpMpBar> createState() => _HpMpBarState();
|
||||||
@@ -368,11 +370,17 @@ class _HpMpBarState extends State<HpMpBar> with TickerProviderStateMixin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 몬스터 HP 바 (레트로 스타일)
|
/// 몬스터 HP 바 (레트로 스타일)
|
||||||
|
/// - HP바 중앙에 HP% 오버레이
|
||||||
|
/// - 하단에 레벨.이름 표시
|
||||||
Widget _buildMonsterBar() {
|
Widget _buildMonsterBar() {
|
||||||
final max = widget.monsterHpMax!;
|
final max = widget.monsterHpMax!;
|
||||||
final ratio = max > 0 ? widget.monsterHpCurrent! / max : 0.0;
|
final ratio = max > 0 ? widget.monsterHpCurrent! / max : 0.0;
|
||||||
const segmentCount = 10;
|
const segmentCount = 10;
|
||||||
final filledSegments = (ratio.clamp(0.0, 1.0) * segmentCount).round();
|
final filledSegments = (ratio.clamp(0.0, 1.0) * segmentCount).round();
|
||||||
|
final levelPrefix = widget.monsterLevel != null
|
||||||
|
? 'Lv.${widget.monsterLevel} '
|
||||||
|
: '';
|
||||||
|
final monsterName = widget.monsterName ?? '';
|
||||||
|
|
||||||
return AnimatedBuilder(
|
return AnimatedBuilder(
|
||||||
animation: _monsterFlashAnimation,
|
animation: _monsterFlashAnimation,
|
||||||
@@ -396,62 +404,78 @@ class _HpMpBarState extends State<HpMpBar> with TickerProviderStateMixin {
|
|||||||
child: Stack(
|
child: Stack(
|
||||||
clipBehavior: Clip.none,
|
clipBehavior: Clip.none,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
// 몬스터 아이콘
|
// HP 바 (HP% 중앙 오버레이)
|
||||||
const Icon(
|
Stack(
|
||||||
Icons.pest_control,
|
alignment: Alignment.center,
|
||||||
size: 12,
|
children: [
|
||||||
color: RetroColors.gold,
|
// 세그먼트 HP 바
|
||||||
),
|
Container(
|
||||||
const SizedBox(width: 6),
|
height: 12,
|
||||||
// 세그먼트 HP 바
|
decoration: BoxDecoration(
|
||||||
Expanded(
|
color: RetroColors.hpRedDark.withValues(alpha: 0.3),
|
||||||
child: Container(
|
border: Border.all(
|
||||||
height: 10,
|
color: RetroColors.panelBorderOuter,
|
||||||
decoration: BoxDecoration(
|
width: 1,
|
||||||
color: RetroColors.hpRedDark.withValues(alpha: 0.3),
|
),
|
||||||
border: Border.all(
|
|
||||||
color: RetroColors.panelBorderOuter,
|
|
||||||
width: 1,
|
|
||||||
),
|
),
|
||||||
),
|
child: Row(
|
||||||
child: Row(
|
children: List.generate(segmentCount, (index) {
|
||||||
children: List.generate(segmentCount, (index) {
|
final isFilled = index < filledSegments;
|
||||||
final isFilled = index < filledSegments;
|
return Expanded(
|
||||||
return Expanded(
|
child: Container(
|
||||||
child: Container(
|
decoration: BoxDecoration(
|
||||||
decoration: BoxDecoration(
|
color: isFilled
|
||||||
color: isFilled
|
? RetroColors.gold
|
||||||
? RetroColors.gold
|
: RetroColors.panelBorderOuter.withValues(
|
||||||
: RetroColors.panelBorderOuter.withValues(
|
alpha: 0.3,
|
||||||
alpha: 0.3,
|
),
|
||||||
),
|
border: Border(
|
||||||
border: Border(
|
right: index < segmentCount - 1
|
||||||
right: index < segmentCount - 1
|
? BorderSide(
|
||||||
? BorderSide(
|
color: RetroColors.panelBorderOuter
|
||||||
color: RetroColors.panelBorderOuter
|
.withValues(alpha: 0.3),
|
||||||
.withValues(alpha: 0.3),
|
width: 1,
|
||||||
width: 1,
|
)
|
||||||
)
|
: BorderSide.none,
|
||||||
: 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),
|
const SizedBox(height: 4),
|
||||||
// HP 퍼센트
|
// 레벨.이름 표시
|
||||||
Text(
|
Text(
|
||||||
'${(ratio * 100).toInt()}%',
|
'$levelPrefix$monsterName',
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontFamily: 'PressStart2P',
|
fontFamily: 'PressStart2P',
|
||||||
fontSize: 8,
|
fontSize: 7,
|
||||||
color: RetroColors.gold,
|
color: RetroColors.gold,
|
||||||
),
|
),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
maxLines: 1,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -259,7 +259,7 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
|
|||||||
seed: gameSeed,
|
seed: gameSeed,
|
||||||
traits: traits,
|
traits: traits,
|
||||||
stats: finalStats,
|
stats: finalStats,
|
||||||
inventory: const Inventory(gold: 0, items: []),
|
inventory: Inventory.empty(),
|
||||||
equipment: Equipment.empty(),
|
equipment: Equipment.empty(),
|
||||||
skillBook: SkillBook.empty(),
|
skillBook: SkillBook.empty(),
|
||||||
progress: ProgressState.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