From ac76060222263b82d1210a26c0fd859639c9ca67 Mon Sep 17 00:00:00 2001 From: JiWoong Sul Date: Thu, 15 Jan 2026 18:01:31 +0900 Subject: [PATCH] =?UTF-8?q?feat(animation):=20Act=20=EA=B8=B0=EB=B0=98=20?= =?UTF-8?q?=EB=AA=AC=EC=8A=A4=ED=84=B0=20=EC=82=AC=EC=9D=B4=EC=A6=88=20?= =?UTF-8?q?=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Act 진행에 따른 몬스터 사이즈 확률 조정 - 보스: Act별 고정 사이즈 (소/중/대) - 일반/엘리트: Act별 확률 랜덤 - TaskInfo에 monsterSize 필드 추가 - 애니메이션 패널에서 Act 기반 사이즈 사용 --- lib/src/core/animation/monster_size.dart | 57 ++++++++++++++++++- .../core/engine/act_progression_service.dart | 2 + lib/src/core/engine/progress_service.dart | 10 ++++ lib/src/core/model/game_state.dart | 9 ++- lib/src/features/game/game_play_screen.dart | 1 + .../game/game_session_controller.dart | 12 +++- .../game/layouts/mobile_carousel_layout.dart | 1 + .../game/widgets/ascii_animation_card.dart | 22 +++++-- .../widgets/enhanced_animation_panel.dart | 6 ++ .../game/widgets/task_progress_panel.dart | 6 ++ 10 files changed, 118 insertions(+), 8 deletions(-) diff --git a/lib/src/core/animation/monster_size.dart b/lib/src/core/animation/monster_size.dart index 5ba0f88..a5e19b3 100644 --- a/lib/src/core/animation/monster_size.dart +++ b/lib/src/core/animation/monster_size.dart @@ -1,5 +1,8 @@ // 몬스터 크기 시스템 -// 몬스터 레벨에 따라 ASCII 아트 크기 결정 +// Act와 몬스터 등급에 따라 ASCII 아트 크기 결정 + +import 'package:asciineverdie/src/core/model/monster_grade.dart'; +import 'package:asciineverdie/src/core/util/deterministic_random.dart'; /// 몬스터 크기 enum /// 실제 프레임 줄 수와 일치하도록 설정 @@ -48,3 +51,55 @@ MonsterSize getMonsterSize(int? level) { int getMonsterVerticalPadding(MonsterSize size) { return (7 - size.lines) ~/ 2; } + +// ============================================================================= +// Act 기반 몬스터 사이즈 결정 (Phase 13) +// ============================================================================= + +/// 보스 몬스터 사이즈 결정 (Act별 고정) +/// +/// - Prologue(1), Act I(2): small (4줄) +/// - Act II(3), Act III(4): medium (6줄) +/// - Act IV(5), Act V(6)+: large (8줄) +MonsterSize getBossSizeForAct(int plotStageCount) { + if (plotStageCount <= 2) return MonsterSize.small; + if (plotStageCount <= 4) return MonsterSize.medium; + return MonsterSize.large; +} + +/// 일반/엘리트 몬스터 사이즈 결정 (Act별 확률 랜덤) +/// +/// Act가 진행될수록 큰 몬스터가 나올 확률 증가. +/// 프롤로그에서는 대형 몬스터 0%. +MonsterSize getRandomSizeForAct(int plotStageCount, DeterministicRandom rng) { + final roll = rng.nextInt(100); + + // Act별 확률 테이블 (small %, medium %, large = 나머지) + final (smallChance, mediumChance) = switch (plotStageCount) { + <= 1 => (70, 30), // Prologue: 70% small, 30% medium, 0% large + 2 => (50, 40), // Act I: 50% small, 40% medium, 10% large + 3 => (35, 40), // Act II: 35% small, 40% medium, 25% large + 4 => (25, 40), // Act III: 25% small, 40% medium, 35% large + 5 => (15, 35), // Act IV: 15% small, 35% medium, 50% large + _ => (10, 25), // Act V+: 10% small, 25% medium, 65% large + }; + + if (roll < smallChance) return MonsterSize.small; + if (roll < smallChance + mediumChance) return MonsterSize.medium; + return MonsterSize.large; +} + +/// 몬스터 등급과 Act에 따른 사이즈 결정 (통합 함수) +/// +/// 보스: Act별 고정 사이즈 +/// 일반/엘리트: Act별 확률 랜덤 +MonsterSize getMonsterSizeForAct({ + required int plotStageCount, + required MonsterGrade grade, + required DeterministicRandom rng, +}) { + if (grade == MonsterGrade.boss) { + return getBossSizeForAct(plotStageCount); + } + return getRandomSizeForAct(plotStageCount, rng); +} diff --git a/lib/src/core/engine/act_progression_service.dart b/lib/src/core/engine/act_progression_service.dart index efa1bb9..92b7113 100644 --- a/lib/src/core/engine/act_progression_service.dart +++ b/lib/src/core/engine/act_progression_service.dart @@ -1,6 +1,7 @@ import 'dart:math' as math; import 'package:asciineverdie/data/game_text_l10n.dart' as l10n; +import 'package:asciineverdie/src/core/animation/monster_size.dart'; import 'package:asciineverdie/src/core/engine/combat_calculator.dart'; import 'package:asciineverdie/src/core/model/combat_state.dart'; import 'package:asciineverdie/src/core/model/combat_stats.dart'; @@ -253,6 +254,7 @@ class ActProgressionService { monsterPart: '*', // 특수 전리품 monsterLevel: glitchGod.level, monsterGrade: MonsterGrade.boss, // 최종 보스는 항상 boss 등급 + monsterSize: MonsterSize.large, // 최종 보스는 항상 대형 ), currentCombat: combatState, ); diff --git a/lib/src/core/engine/progress_service.dart b/lib/src/core/engine/progress_service.dart index 7ee4d40..b23eec5 100644 --- a/lib/src/core/engine/progress_service.dart +++ b/lib/src/core/engine/progress_service.dart @@ -1,6 +1,7 @@ import 'dart:math' as math; import 'package:asciineverdie/data/game_text_l10n.dart' as l10n; +import 'package:asciineverdie/src/core/animation/monster_size.dart'; import 'package:asciineverdie/src/core/engine/act_progression_service.dart'; import 'package:asciineverdie/src/core/engine/combat_calculator.dart'; import 'package:asciineverdie/src/core/engine/combat_tick_service.dart'; @@ -585,6 +586,7 @@ class ProgressService { monsterPart: '*', // Boss는 WinItem 드랍 monsterLevel: actBoss.monsterStats.level, monsterGrade: MonsterGrade.boss, + monsterSize: getBossSizeForAct(state.progress.plotStageCount), ), currentCombat: actBoss, ); @@ -673,6 +675,13 @@ class ProgressService { durationMillis, ); + // 몬스터 사이즈 결정 (Act 기반, Phase 13) + final monsterSize = getMonsterSizeForAct( + plotStageCount: state.progress.plotStageCount, + grade: monsterResult.grade, + rng: state.rng, + ); + progress = taskResult.progress.copyWith( currentTask: TaskInfo( caption: taskResult.caption, @@ -681,6 +690,7 @@ class ProgressService { monsterPart: monsterResult.part, monsterLevel: effectiveMonsterLevel, monsterGrade: monsterResult.grade, + monsterSize: monsterSize, ), currentCombat: combatState, ); diff --git a/lib/src/core/model/game_state.dart b/lib/src/core/model/game_state.dart index e6dce1a..754c4b4 100644 --- a/lib/src/core/model/game_state.dart +++ b/lib/src/core/model/game_state.dart @@ -1,5 +1,6 @@ import 'dart:collection'; +import 'package:asciineverdie/src/core/animation/monster_size.dart'; import 'package:asciineverdie/src/core/model/combat_event.dart'; import 'package:asciineverdie/src/core/model/combat_state.dart'; import 'package:asciineverdie/src/core/model/equipment_item.dart'; @@ -315,6 +316,7 @@ class TaskInfo { this.monsterPart, this.monsterLevel, this.monsterGrade, + this.monsterSize, }); final String caption; @@ -326,12 +328,15 @@ class TaskInfo { /// 킬 태스크의 전리품 부위 (예: "claw", "tail", "*"는 WinItem) final String? monsterPart; - /// 킬 태스크의 몬스터 레벨 (애니메이션 크기 결정용) + /// 킬 태스크의 몬스터 레벨 (전투 스탯 계산용) final int? monsterLevel; /// 킬 태스크의 몬스터 등급 (Normal/Elite/Boss) final MonsterGrade? monsterGrade; + /// 킬 태스크의 몬스터 사이즈 (애니메이션 크기 결정용, Act 기반) + final MonsterSize? monsterSize; + factory TaskInfo.empty() => const TaskInfo(caption: '', type: TaskType.neutral); @@ -342,6 +347,7 @@ class TaskInfo { String? monsterPart, int? monsterLevel, MonsterGrade? monsterGrade, + MonsterSize? monsterSize, }) { return TaskInfo( caption: caption ?? this.caption, @@ -350,6 +356,7 @@ class TaskInfo { monsterPart: monsterPart ?? this.monsterPart, monsterLevel: monsterLevel ?? this.monsterLevel, monsterGrade: monsterGrade ?? this.monsterGrade, + monsterSize: monsterSize ?? this.monsterSize, ); } } diff --git a/lib/src/features/game/game_play_screen.dart b/lib/src/features/game/game_play_screen.dart index 8e7965a..458b268 100644 --- a/lib/src/features/game/game_play_screen.dart +++ b/lib/src/features/game/game_play_screen.dart @@ -734,6 +734,7 @@ class _GamePlayScreenState extends State characterLevel: state.traits.level, monsterLevel: state.progress.currentTask.monsterLevel, monsterGrade: state.progress.currentTask.monsterGrade, + monsterSize: state.progress.currentTask.monsterSize, latestCombatEvent: state.progress.currentCombat?.recentEvents.lastOrNull, raceId: state.traits.raceId, diff --git a/lib/src/features/game/game_session_controller.dart b/lib/src/features/game/game_session_controller.dart index a83fff4..76fba55 100644 --- a/lib/src/features/game/game_session_controller.dart +++ b/lib/src/features/game/game_session_controller.dart @@ -48,6 +48,9 @@ class GameSessionController extends ChangeNotifier { GameState? _state; String? _error; + // 배속 저장 (pause/resume 시 유지) + int _savedSpeedMultiplier = 1; + // 자동 부활 (Auto-Resurrection) 상태 bool _autoResurrect = false; @@ -89,7 +92,8 @@ class GameSessionController extends ChangeNotifier { bool isNewGame = true, }) async { // 기존 배속 보존 (부활/재개 시 유지) - final previousSpeed = _loop?.speedMultiplier ?? 1; + // _loop가 있으면 현재 배속 사용, 없으면 저장된 배속 사용 + final previousSpeed = _loop?.speedMultiplier ?? _savedSpeedMultiplier; await _stopLoop(saveOnStop: false); @@ -284,6 +288,12 @@ class GameSessionController extends ChangeNotifier { Future? _stopLoop({required bool saveOnStop}) { final loop = _loop; final sub = _subscription; + + // 배속 저장 (resume 시 복원용) + if (loop != null) { + _savedSpeedMultiplier = loop.speedMultiplier; + } + _loop = null; _subscription = null; diff --git a/lib/src/features/game/layouts/mobile_carousel_layout.dart b/lib/src/features/game/layouts/mobile_carousel_layout.dart index 7def71a..d5d2e78 100644 --- a/lib/src/features/game/layouts/mobile_carousel_layout.dart +++ b/lib/src/features/game/layouts/mobile_carousel_layout.dart @@ -730,6 +730,7 @@ class _MobileCarouselLayoutState extends State { characterLevel: state.traits.level, monsterLevel: state.progress.currentTask.monsterLevel, monsterGrade: state.progress.currentTask.monsterGrade, + monsterSize: state.progress.currentTask.monsterSize, latestCombatEvent: state.progress.currentCombat?.recentEvents.lastOrNull, raceId: state.traits.raceId, diff --git a/lib/src/features/game/widgets/ascii_animation_card.dart b/lib/src/features/game/widgets/ascii_animation_card.dart index fb07ffd..081f879 100644 --- a/lib/src/features/game/widgets/ascii_animation_card.dart +++ b/lib/src/features/game/widgets/ascii_animation_card.dart @@ -47,6 +47,7 @@ class AsciiAnimationCard extends StatefulWidget { this.characterLevel, this.monsterLevel, this.monsterGrade, + this.monsterSize, this.isPaused = false, this.isInCombat = true, this.monsterDied = false, @@ -85,12 +86,15 @@ class AsciiAnimationCard extends StatefulWidget { /// 캐릭터 레벨 final int? characterLevel; - /// 몬스터 레벨 (몬스터 크기 결정용) + /// 몬스터 레벨 (전투 스탯 계산용) final int? monsterLevel; /// 몬스터 등급 (Normal/Elite/Boss) - 색상/접두사 표시용 final MonsterGrade? monsterGrade; + /// 몬스터 사이즈 (애니메이션 크기 결정용, Act 기반) + final MonsterSize? monsterSize; + /// 최근 전투 이벤트 (애니메이션 동기화용) final CombatEvent? latestCombatEvent; @@ -175,7 +179,8 @@ class _AsciiAnimationCardState extends State { bool _showDeathAnimation = false; List? _deathAnimationMonsterLines; String? _lastMonsterBaseName; - int? _lastMonsterLevel; // 몬스터 레벨 캐시 (사망 시 크기 결정용) + int? _lastMonsterLevel; // 몬스터 레벨 캐시 (사망 시 스탯용) + MonsterSize? _lastMonsterSize; // 몬스터 사이즈 캐시 (사망 시 크기 결정용) @override void initState() { @@ -245,6 +250,9 @@ class _AsciiAnimationCardState extends State { if (widget.monsterLevel != null) { _lastMonsterLevel = widget.monsterLevel; } + if (widget.monsterSize != null) { + _lastMonsterSize = widget.monsterSize; + } // 새 몬스터 등장 시 사망 애니메이션 상태 리셋 // (이전 몬스터 사망 애니메이션이 끝나기 전에 새 전투 시작 시 대응) @@ -591,7 +599,9 @@ class _AsciiAnimationCardState extends State { final hasShield = widget.shieldName != null && widget.shieldName!.isNotEmpty; final monsterCategory = getMonsterCategory(widget.monsterBaseName); - final monsterSize = getMonsterSize(widget.monsterLevel); + // Act 기반 사이즈 사용 (Phase 13), 없으면 레벨 기반 fallback + final monsterSize = + widget.monsterSize ?? getMonsterSize(widget.monsterLevel); _battleComposer = CanvasBattleComposer( weaponCategory: weaponCategory, @@ -617,8 +627,10 @@ class _AsciiAnimationCardState extends State { if (monsterName == null) return null; final monsterCategory = getMonsterCategory(monsterName); - // 캐시된 레벨 사용 (사망 시점에 widget.monsterLevel이 null일 수 있음) - final monsterSize = getMonsterSize(_lastMonsterLevel ?? widget.monsterLevel); + // 캐시된 사이즈 사용 (사망 시점에 widget.monsterSize가 null일 수 있음) + final monsterSize = _lastMonsterSize ?? + widget.monsterSize ?? + getMonsterSize(_lastMonsterLevel ?? widget.monsterLevel); // 몬스터 Idle 프레임 가져오기 final frames = getMonsterIdleFrames(monsterCategory, monsterSize); diff --git a/lib/src/features/game/widgets/enhanced_animation_panel.dart b/lib/src/features/game/widgets/enhanced_animation_panel.dart index 42a66e4..9bfc7ff 100644 --- a/lib/src/features/game/widgets/enhanced_animation_panel.dart +++ b/lib/src/features/game/widgets/enhanced_animation_panel.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:asciineverdie/data/game_text_l10n.dart' as l10n; import 'package:asciineverdie/src/core/animation/ascii_animation_type.dart'; +import 'package:asciineverdie/src/core/animation/monster_size.dart'; import 'package:asciineverdie/src/core/model/combat_event.dart'; import 'package:asciineverdie/src/core/model/combat_state.dart'; import 'package:asciineverdie/src/core/model/game_state.dart'; @@ -32,6 +33,7 @@ class EnhancedAnimationPanel extends StatefulWidget { this.characterLevel, this.monsterLevel, this.monsterGrade, + this.monsterSize, this.latestCombatEvent, this.raceId, this.weaponRarity, @@ -52,6 +54,9 @@ class EnhancedAnimationPanel extends StatefulWidget { /// 몬스터 등급 (Normal/Elite/Boss) - UI 색상/접두사 표시용 final MonsterGrade? monsterGrade; + + /// 몬스터 사이즈 (애니메이션 크기 결정용, Act 기반) + final MonsterSize? monsterSize; final CombatEvent? latestCombatEvent; /// 종족 ID (Phase 4: 종족별 캐릭터 애니메이션) @@ -225,6 +230,7 @@ class _EnhancedAnimationPanelState extends State characterLevel: widget.characterLevel, monsterLevel: widget.monsterLevel, monsterGrade: widget.monsterGrade, + monsterSize: widget.monsterSize, isPaused: widget.isPaused, isInCombat: isInCombat, monsterDied: _monsterDied, diff --git a/lib/src/features/game/widgets/task_progress_panel.dart b/lib/src/features/game/widgets/task_progress_panel.dart index a746580..47184a6 100644 --- a/lib/src/features/game/widgets/task_progress_panel.dart +++ b/lib/src/features/game/widgets/task_progress_panel.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:asciineverdie/data/game_text_l10n.dart' as game_l10n; import 'package:asciineverdie/l10n/app_localizations.dart'; import 'package:asciineverdie/src/core/animation/ascii_animation_type.dart'; +import 'package:asciineverdie/src/core/animation/monster_size.dart'; import 'package:asciineverdie/src/core/model/combat_event.dart'; import 'package:asciineverdie/src/core/model/game_state.dart'; import 'package:asciineverdie/src/core/model/monster_grade.dart'; @@ -25,6 +26,7 @@ class TaskProgressPanel extends StatelessWidget { this.characterLevel, this.monsterLevel, this.monsterGrade, + this.monsterSize, this.latestCombatEvent, this.raceId, }); @@ -49,6 +51,9 @@ class TaskProgressPanel extends StatelessWidget { /// 몬스터 등급 (Normal/Elite/Boss) - UI 색상/접두사 표시용 final MonsterGrade? monsterGrade; + /// 몬스터 사이즈 (애니메이션 크기 결정용, Act 기반) + final MonsterSize? monsterSize; + /// 최근 전투 이벤트 (애니메이션 동기화용, Phase 5) final CombatEvent? latestCombatEvent; @@ -80,6 +85,7 @@ class TaskProgressPanel extends StatelessWidget { characterLevel: characterLevel, monsterLevel: monsterLevel, monsterGrade: monsterGrade, + monsterSize: monsterSize, isPaused: isPaused, latestCombatEvent: latestCombatEvent, raceId: raceId,