feat(arena): 아레나 전투 로그 위젯 추가
- ArenaCombatLog: 전투 로그 표시 위젯 - ArenaBattleScreen 연동
This commit is contained in:
@@ -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/game_state.dart';
|
||||||
import 'package:asciineverdie/src/core/model/hall_of_fame.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/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/arena_result_panel.dart';
|
||||||
import 'package:asciineverdie/src/features/arena/widgets/ascii_disintegrate_widget.dart';
|
import 'package:asciineverdie/src/features/arena/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';
|
||||||
@@ -376,8 +377,13 @@ class _ArenaBattleScreenState extends State<ArenaBattleScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _endBattle() {
|
void _endBattle() {
|
||||||
// 최종 결과 계산
|
// 시뮬레이션 HP 결과를 기반으로 최종 결과 계산
|
||||||
_result = _arenaService.executeCombat(widget.match);
|
_result = _arenaService.createResultFromSimulation(
|
||||||
|
match: widget.match,
|
||||||
|
challengerHp: _challengerHp,
|
||||||
|
opponentHp: _opponentHp,
|
||||||
|
turns: _currentTurn,
|
||||||
|
);
|
||||||
|
|
||||||
// 전투 종료 상태로 전환 (인라인 결과 패널 표시)
|
// 전투 종료 상태로 전환 (인라인 결과 패널 표시)
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -836,7 +842,7 @@ class _ArenaBattleScreenState extends State<ArenaBattleScreen>
|
|||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
border: Border.all(color: RetroColors.borderOf(context)),
|
border: Border.all(color: RetroColors.borderOf(context)),
|
||||||
),
|
),
|
||||||
child: CombatLog(entries: _battleLog),
|
child: ArenaCombatLog(entries: _battleLog),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
199
lib/src/features/arena/widgets/arena_combat_log.dart
Normal file
199
lib/src/features/arena/widgets/arena_combat_log.dart
Normal file
@@ -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<CombatLogEntry> entries;
|
||||||
|
final int maxEntries;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ArenaCombatLog> createState() => _ArenaCombatLogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ArenaCombatLogState extends State<ArenaCombatLog> {
|
||||||
|
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<ScrollNotification>(
|
||||||
|
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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user