From c3a8bc305a30a0b652138b8563c0703d2c2921f8 Mon Sep 17 00:00:00 2001 From: JiWoong Sul Date: Tue, 6 Jan 2026 19:19:05 +0900 Subject: [PATCH] =?UTF-8?q?feat(arena):=20=EC=95=84=EB=A0=88=EB=82=98=20?= =?UTF-8?q?=EC=A0=84=ED=88=AC=20=EB=A1=9C=EA=B7=B8=20=EC=9C=84=EC=A0=AF=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ArenaCombatLog: 전투 로그 표시 위젯 - ArenaBattleScreen 연동 --- .../features/arena/arena_battle_screen.dart | 12 +- .../arena/widgets/arena_combat_log.dart | 199 ++++++++++++++++++ 2 files changed, 208 insertions(+), 3 deletions(-) create mode 100644 lib/src/features/arena/widgets/arena_combat_log.dart diff --git a/lib/src/features/arena/arena_battle_screen.dart b/lib/src/features/arena/arena_battle_screen.dart index b09ae39..75d2107 100644 --- a/lib/src/features/arena/arena_battle_screen.dart +++ b/lib/src/features/arena/arena_battle_screen.dart @@ -8,6 +8,7 @@ import 'package:asciineverdie/src/core/model/combat_event.dart'; import 'package:asciineverdie/src/core/model/game_state.dart'; 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/features/game/widgets/ascii_animation_card.dart'; @@ -376,8 +377,13 @@ class _ArenaBattleScreenState extends State } void _endBattle() { - // 최종 결과 계산 - _result = _arenaService.executeCombat(widget.match); + // 시뮬레이션 HP 결과를 기반으로 최종 결과 계산 + _result = _arenaService.createResultFromSimulation( + match: widget.match, + challengerHp: _challengerHp, + opponentHp: _opponentHp, + turns: _currentTurn, + ); // 전투 종료 상태로 전환 (인라인 결과 패널 표시) setState(() { @@ -836,7 +842,7 @@ class _ArenaBattleScreenState extends State borderRadius: BorderRadius.circular(8), border: Border.all(color: RetroColors.borderOf(context)), ), - child: CombatLog(entries: _battleLog), + child: ArenaCombatLog(entries: _battleLog), ); } diff --git a/lib/src/features/arena/widgets/arena_combat_log.dart b/lib/src/features/arena/widgets/arena_combat_log.dart new file mode 100644 index 0000000..2d0a20d --- /dev/null +++ b/lib/src/features/arena/widgets/arena_combat_log.dart @@ -0,0 +1,199 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import 'package:asciineverdie/src/features/game/widgets/combat_log.dart'; + +/// 아레나 전용 전투 로그 위젯 +/// +/// 일반 CombatLog와 다른 점: +/// - 최신 메시지가 상단에 표시 (reverse order) +/// - 사용자 조작 시 자동 스크롤 중지 +/// - 5초 미조작 시 자동 스크롤 재개 +class ArenaCombatLog extends StatefulWidget { + const ArenaCombatLog({ + super.key, + required this.entries, + this.maxEntries = 50, + }); + + final List entries; + final int maxEntries; + + @override + State createState() => _ArenaCombatLogState(); +} + +class _ArenaCombatLogState extends State { + final ScrollController _scrollController = ScrollController(); + + /// 자동 스크롤 활성화 여부 + bool _autoScrollEnabled = true; + + /// 사용자 조작 후 자동 스크롤 재개 타이머 + Timer? _resumeAutoScrollTimer; + + /// 자동 스크롤 재개까지 대기 시간 (5초) + static const _autoScrollResumeDelay = Duration(seconds: 5); + + int _previousLength = 0; + + @override + void didUpdateWidget(ArenaCombatLog oldWidget) { + super.didUpdateWidget(oldWidget); + + // 새 로그 추가 시 자동 스크롤 (활성화된 경우에만) + if (widget.entries.length > _previousLength && _autoScrollEnabled) { + _scrollToTop(); + } + _previousLength = widget.entries.length; + } + + /// 최상단으로 스크롤 (최신 메시지가 상단이므로 position 0) + void _scrollToTop() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_scrollController.hasClients) { + _scrollController.animateTo( + 0, + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + ); + } + }); + } + + /// 사용자 스크롤 감지 시 호출 + void _onUserScroll() { + // 자동 스크롤 비활성화 + setState(() { + _autoScrollEnabled = false; + }); + + // 기존 타이머 취소 + _resumeAutoScrollTimer?.cancel(); + + // 5초 후 자동 스크롤 재활성화 + _resumeAutoScrollTimer = Timer(_autoScrollResumeDelay, () { + if (mounted) { + setState(() { + _autoScrollEnabled = true; + }); + // 재활성화 시 최상단으로 스크롤 + _scrollToTop(); + } + }); + } + + @override + void dispose() { + _resumeAutoScrollTimer?.cancel(); + _scrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + // 최신 메시지가 상단에 오도록 역순 리스트 생성 + final reversedEntries = widget.entries.reversed.toList(); + + return NotificationListener( + onNotification: (notification) { + // 사용자가 직접 스크롤할 때만 감지 (UserScrollNotification) + if (notification is UserScrollNotification) { + _onUserScroll(); + } + return false; + }, + child: ListView.builder( + controller: _scrollController, + itemCount: reversedEntries.length, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + itemBuilder: (context, index) { + final entry = reversedEntries[index]; + return _ArenaLogEntryTile(entry: entry); + }, + ), + ); + } +} + +/// 개별 로그 엔트리 타일 (아레나용) +class _ArenaLogEntryTile extends StatelessWidget { + const _ArenaLogEntryTile({required this.entry}); + + final CombatLogEntry entry; + + @override + Widget build(BuildContext context) { + final (color, icon) = _getStyleForType(entry.type); + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 1), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 타임스탬프(timestamp) + Text( + _formatTime(entry.timestamp), + style: TextStyle( + fontSize: 10, + color: Theme.of(context).colorScheme.outline, + fontFamily: 'JetBrainsMono', + ), + ), + const SizedBox(width: 4), + // 아이콘 + if (icon != null) + Padding( + padding: const EdgeInsets.only(right: 4), + child: Icon(icon, size: 12, color: color), + ), + // 메시지 + Expanded( + child: Text( + entry.message, + style: TextStyle( + fontSize: 11, + color: color ?? Theme.of(context).colorScheme.onSurface, + ), + ), + ), + ], + ), + ); + } + + String _formatTime(DateTime time) { + return '${time.hour.toString().padLeft(2, '0')}:' + '${time.minute.toString().padLeft(2, '0')}:' + '${time.second.toString().padLeft(2, '0')}'; + } + + (Color?, IconData?) _getStyleForType(CombatLogType type) { + return switch (type) { + CombatLogType.normal => (null, null), + CombatLogType.damage => ( + Colors.red.shade300, + Icons.local_fire_department, + ), + CombatLogType.heal => (Colors.green.shade300, Icons.healing), + CombatLogType.levelUp => (Colors.amber, Icons.arrow_upward), + CombatLogType.questComplete => (Colors.blue.shade300, Icons.check_circle), + CombatLogType.loot => (Colors.orange.shade300, Icons.inventory_2), + CombatLogType.skill => (Colors.purple.shade300, Icons.auto_fix_high), + CombatLogType.critical => (Colors.yellow.shade300, Icons.flash_on), + CombatLogType.evade => (Colors.cyan.shade300, Icons.directions_run), + CombatLogType.block => (Colors.blueGrey.shade300, Icons.shield), + CombatLogType.parry => (Colors.teal.shade300, Icons.sports_kabaddi), + CombatLogType.monsterAttack => ( + Colors.deepOrange.shade300, + Icons.dangerous, + ), + CombatLogType.buff => (Colors.lightBlue.shade300, Icons.trending_up), + CombatLogType.debuff => (Colors.deepOrange.shade300, Icons.trending_down), + CombatLogType.dotTick => (Colors.deepPurple.shade300, Icons.whatshot), + CombatLogType.potion => (Colors.pink.shade300, Icons.local_drink), + CombatLogType.potionDrop => (Colors.lime.shade300, Icons.card_giftcard), + }; + } +}