import 'dart:async'; import 'dart:math' as math; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:provider/provider.dart'; import 'package:aslan/app_localizations.dart'; import 'package:aslan/chatvibe_ui/components/at_debounce_widget.dart'; import 'package:aslan/chatvibe_ui/components/dialog/dialog_base.dart'; import 'package:aslan/chatvibe_managers/rtc_manager.dart'; import 'package:aslan/chatvibe_features/room/seat/seat_item.dart'; import 'package:aslan/chatvibe_features/webview/game_bs_webview_page.dart'; import 'package:aslan/chatvibe_ui/widgets/room/lucky_gift_tag_widget.dart'; import 'package:aslan/chatvibe_ui/widgets/headdress/headdress_widget.dart'; import '../../../../chatvibe_data/models/enum/at_game_mode_type.dart'; import '../../../../chatvibe_domain/models/res/login_res.dart'; import '../../../../chatvibe_domain/models/res/mic_res.dart'; class RoomSeatWidget extends StatefulWidget { const RoomSeatWidget({super.key}); @override _RoomSeatWidgetState createState() => _RoomSeatWidgetState(); } class _RoomSeatWidgetState extends State { static const Duration _cpHeartLoopInterval = Duration(milliseconds: 3000); static const Duration _cpHeartReplyDelay = Duration(milliseconds: 360); static const int _cpHeartBurstCount = 2; static const Duration _cpHeartBurstStagger = Duration(milliseconds: 140); final GlobalKey _stackKey = GlobalKey(); final List<_CpHeartFlightData> _activeFlights = <_CpHeartFlightData>[]; Timer? _cpHeartLoopTimer; int _nextFlightId = 0; int _lastLayoutDigest = -1; List<_CpSeatPair> _cpAdjacentPairs = const <_CpSeatPair>[]; List<_CpSeatPair> _cpRemotePairs = const <_CpSeatPair>[]; Map _seatCenters = const {}; Map _seatSizes = const {}; @override void dispose() { _cpHeartLoopTimer?.cancel(); super.dispose(); } @override Widget build(BuildContext context) { return Consumer( builder: (context, ref, child) { final bool isGaming = ref.playingLudoGame != null && !ref.isMinLudoGame; _scheduleCpHeartRefresh(_buildCpLayoutDigest(ref, isGaming), ref); if (isGaming) { return Stack( key: _stackKey, alignment: Alignment.center, children: [ Column( mainAxisSize: MainAxisSize.min, children: [ SizedBox(height: 5.w), ref.roomWheatMap.isNotEmpty ? Container( padding: EdgeInsets.symmetric(horizontal: 15.w), height: 45.w, child: Center( child: SingleChildScrollView( scrollDirection: Axis.horizontal, physics: const ClampingScrollPhysics(), child: Row( spacing: 15.w, mainAxisSize: MainAxisSize.min, // 宽度自适应内容 children: List.generate( ref.roomWheatMap.length, (index) => SeatItem(index: index, isGameModel: true), ), ), ), ), ) : Container(), RepaintBoundary( child: GameBsWebviewPage( url: ref.playingLudoGame?.gameInfo?.gameCode ?? "", height: 860.w, width: ScreenUtil().screenWidth, gameMode: ATGameModeType.CHAT_ROOM.name, gameId: ref.playingLudoGame?.gameInfo?.id ?? "", ), ), SizedBox(height: 5.w), !ref.isMinLudoGame ? Row( children: [ Spacer(), ATDebounceWidget( child: Image.asset( "atu_images/room/at_icon_room_game_min.png", width: 20.w, height: 20.w, ), onTap: () { SmartDialog.show( tag: "showConfirmDialog", alignment: Alignment.center, debounce: true, animationType: SmartAnimationType.fade, builder: (_) { return MsgDialog( title: ATAppLocalizations.of(context)!.tips, msg: ATAppLocalizations.of( context, )!.returnToVoiceChat, btnText: ATAppLocalizations.of(context)!.confirm, onEnsure: () { ref.minOrShowLudoGame(true); }, ); }, ); }, ), SizedBox(width: 8.w), ref.isFz() ? ATDebounceWidget( child: Image.asset( "atu_images/room/at_icon_room_game_close.png", width: 20.w, height: 20.w, ), onTap: () { SmartDialog.show( tag: "showConfirmDialog", alignment: Alignment.center, debounce: true, animationType: SmartAnimationType.fade, builder: (_) { return MsgDialog( title: ATAppLocalizations.of( context, )!.tips, msg: ATAppLocalizations.of( context, )!.exitGameMode, btnText: ATAppLocalizations.of( context, )!.confirm, onEnsure: () { ref.gameLudoDisband(); }, ); }, ); }, ) : Container(), ref.isFz() ? SizedBox(width: 8.w) : Container(), ], ) : Container(), ], ), LuckyGiftTagWidget(), Positioned.fill( child: IgnorePointer(child: _buildCpAdjacencyOverlay()), ), Positioned.fill( child: IgnorePointer(child: _buildCpHeartOverlay()), ), ], ); } return Stack( key: _stackKey, children: [ ref.roomWheatMap.length == 5 ? _buildSeat5() : (ref.roomWheatMap.length == 10 ? _buildSeat10() : (ref.roomWheatMap.length == 15 ? _buildSeat15() : (ref.roomWheatMap.length == 20 ? _buildSeat20() : Container(height: 180.w)))), Positioned.fill( child: IgnorePointer(child: _buildCpAdjacencyOverlay()), ), Positioned.fill( child: IgnorePointer(child: _buildCpHeartOverlay()), ), ], ); }, ); } int _buildCpLayoutDigest(RtcProvider rtcProvider, bool isGaming) { int digest = rtcProvider.roomWheatMap.length; for (final entry in rtcProvider.roomWheatMap.entries) { final MicRes mic = entry.value; final String userId = mic.user?.id ?? ''; digest = Object.hash(digest, entry.key, userId); for (final cp in mic.user?.cpList ?? const []) { digest = Object.hash( digest, cp.cpUserId ?? '', cp.micEffectLevel ?? 0, cp.hasEffectLevel == true ? 1 : 0, ); } } return Object.hash(digest, isGaming ? 1 : 0); } void _scheduleCpHeartRefresh(int digest, RtcProvider rtcProvider) { if (_lastLayoutDigest == digest) { return; } _lastLayoutDigest = digest; WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) { return; } _refreshCpHeartState(rtcProvider); }); } void _refreshCpHeartState(RtcProvider rtcProvider) { final RenderBox? stackBox = _stackKey.currentContext?.findRenderObject() as RenderBox?; if (stackBox == null || !stackBox.hasSize) { return; } final Map nextCenters = {}; final Map nextSizes = {}; for (final entry in rtcProvider.seatGlobalKeyMap.entries) { final int index = entry.key.toInt(); final BuildContext? seatContext = entry.value.currentContext; final RenderBox? seatBox = seatContext?.findRenderObject() as RenderBox?; if (seatBox == null || !seatBox.hasSize) { continue; } final Offset topLeft = seatBox.localToGlobal( Offset.zero, ancestor: stackBox, ); nextSizes[index] = seatBox.size; nextCenters[index] = topLeft + Offset(seatBox.size.width / 2, seatBox.size.height / 2); } final List<_CpSeatPair> nextPairs = _collectCpSeatPairs(rtcProvider); final _CpPairGroups nextGroups = _splitCpPairsByDistance( nextPairs, nextCenters, ); final bool pairChanged = !_samePairs(_cpAdjacentPairs, nextGroups.adjacent) || !_samePairs(_cpRemotePairs, nextGroups.remote); _seatCenters = nextCenters; _seatSizes = nextSizes; _cpAdjacentPairs = nextGroups.adjacent; _cpRemotePairs = nextGroups.remote; if (pairChanged) { setState(() { _activeFlights.clear(); }); } _configureCpHeartLoop(); } List<_CpSeatPair> _collectCpSeatPairs(RtcProvider rtcProvider) { final List<_CpSeatPair> pairs = <_CpSeatPair>[]; final Set seen = {}; for (final entry in rtcProvider.roomWheatMap.entries) { final int fromIndex = entry.key.toInt(); final ChatVibeUserProfile? user = entry.value.user; final String fromUserId = user?.id ?? ''; if (fromUserId.isEmpty) { continue; } for (final CPRes cp in user?.cpList ?? const []) { final String toUserId = cp.cpUserId ?? ''; if (toUserId.isEmpty) { continue; } final MapEntry? targetEntry = rtcProvider .roomWheatMap .entries .cast?>() .firstWhere( (candidate) => candidate?.value.user?.id == toUserId, orElse: () => null, ); if (targetEntry == null) { continue; } final ChatVibeUserProfile? targetUser = targetEntry.value.user; final int? micEffectLevel = _resolveMutualCpLevel(user, targetUser); if (micEffectLevel == null) { continue; } final int toIndex = targetEntry.key.toInt(); final int start = math.min(fromIndex, toIndex); final int end = math.max(fromIndex, toIndex); final String pairKey = '$start-$end'; if (!seen.add(pairKey)) { continue; } pairs.add( _CpSeatPair( fromIndex: start, toIndex: end, micEffectLevel: micEffectLevel, hasNonMicEffect: _resolveMutualNonMicEffectEnabled( user, targetUser, ), ), ); } } return pairs; } int? _resolveMutualCpLevel( ChatVibeUserProfile? userA, ChatVibeUserProfile? userB, ) { final String userAId = userA?.id ?? ''; final String userBId = userB?.id ?? ''; if (userAId.isEmpty || userBId.isEmpty) { return null; } final CPRes? aRelation = (userA?.cpList ?? const []) .cast() .firstWhere((cp) => cp?.cpUserId == userBId, orElse: () => null); final CPRes? bRelation = (userB?.cpList ?? const []) .cast() .firstWhere((cp) => cp?.cpUserId == userAId, orElse: () => null); if (aRelation == null || bRelation == null) { return null; } final int aLevel = (aRelation.micEffectLevel ?? 1).toInt(); final int bLevel = (bRelation.micEffectLevel ?? 1).toInt(); return math.max(aLevel, bLevel).clamp(1, 3); } bool _resolveMutualNonMicEffectEnabled( ChatVibeUserProfile? userA, ChatVibeUserProfile? userB, ) { final String userAId = userA?.id ?? ''; final String userBId = userB?.id ?? ''; if (userAId.isEmpty || userBId.isEmpty) { return false; } final CPRes? aRelation = (userA?.cpList ?? const []) .cast() .firstWhere((cp) => cp?.cpUserId == userBId, orElse: () => null); final CPRes? bRelation = (userB?.cpList ?? const []) .cast() .firstWhere((cp) => cp?.cpUserId == userAId, orElse: () => null); return aRelation?.hasEffectLevel == true || bRelation?.hasEffectLevel == true; } String _cpAdjacencyResourceForLevel(int micEffectLevel) { switch (micEffectLevel) { case 1: return "atu_images/room/at_icon_cp_mic_anm_lv_1.svga"; case 3: return "atu_images/room/at_icon_cp_mic_anm_lv_3.svga"; case 2: default: return "atu_images/room/at_icon_cp_mic_anm_lv_2.svga"; } } bool _samePairs(List<_CpSeatPair> left, List<_CpSeatPair> right) { if (left.length != right.length) { return false; } for (int i = 0; i < left.length; i++) { if (left[i] != right[i]) { return false; } } return true; } _CpPairGroups _splitCpPairsByDistance( List<_CpSeatPair> pairs, Map seatCenters, ) { if (pairs.isEmpty || seatCenters.length < 2) { return const _CpPairGroups( adjacent: <_CpSeatPair>[], remote: <_CpSeatPair>[], ); } final List occupiedCenters = seatCenters.values.toList(); double minSeatDistance = double.infinity; for (int i = 0; i < occupiedCenters.length; i++) { for (int j = i + 1; j < occupiedCenters.length; j++) { final double distance = (occupiedCenters[i] - occupiedCenters[j]).distance; if (distance < minSeatDistance) { minSeatDistance = distance; } } } if (!minSeatDistance.isFinite) { return _CpPairGroups(adjacent: pairs, remote: const <_CpSeatPair>[]); } final double adjacentThreshold = minSeatDistance * 1.18; final List<_CpSeatPair> adjacent = <_CpSeatPair>[]; final List<_CpSeatPair> remote = <_CpSeatPair>[]; for (final _CpSeatPair pair in pairs) { final Offset? from = seatCenters[pair.fromIndex]; final Offset? to = seatCenters[pair.toIndex]; if (from == null || to == null) { continue; } final double distance = (from - to).distance; if (distance <= adjacentThreshold) { adjacent.add(pair); } else if (pair.hasNonMicEffect) { remote.add(pair); } } return _CpPairGroups(adjacent: adjacent, remote: remote); } void _configureCpHeartLoop() { _cpHeartLoopTimer?.cancel(); if (_cpRemotePairs.isEmpty || _seatCenters.isEmpty) { if (mounted && _activeFlights.isNotEmpty) { setState(() { _activeFlights.clear(); }); } return; } _emitCpHeartFlights(); _cpHeartLoopTimer = Timer.periodic(_cpHeartLoopInterval, (_) { if (!mounted) { return; } _emitCpHeartFlights(); }); } void _emitCpHeartFlights() { for (final _CpSeatPair pair in _cpRemotePairs) { _emitHeartBurst(pair.fromIndex, pair.toIndex); Timer(_cpHeartReplyDelay, () { if (!mounted || !_cpRemotePairs.contains(pair)) { return; } _emitHeartBurst(pair.toIndex, pair.fromIndex); }); } } void _emitHeartBurst(int fromIndex, int toIndex) { for (int i = 0; i < _cpHeartBurstCount; i++) { final Duration delay = Duration( milliseconds: _cpHeartBurstStagger.inMilliseconds * i, ); if (delay == Duration.zero) { _addHeartFlight(fromIndex, toIndex, variant: i); continue; } Timer(delay, () { if (!mounted) { return; } _addHeartFlight(fromIndex, toIndex, variant: i); }); } } void _addHeartFlight(int fromIndex, int toIndex, {int variant = 0}) { final Offset? from = _seatCenters[fromIndex]; final Offset? to = _seatCenters[toIndex]; if (from == null || to == null) { return; } final double distance = (to - from).distance; final double direction = from.dx <= to.dx ? 1 : -1; final double arcHeight = math.max( 20.w, distance * (0.10 + (variant % 3) * 0.025), ); final double controlOffsetX = direction * ((variant.isEven ? 1 : -1) * (8.w + (variant * 3.w))); final double startYOffset = (variant.isEven ? -1 : 1) * (variant * 1.5.w); setState(() { _activeFlights.add( _CpHeartFlightData( id: _nextFlightId++, from: from.translate(0, startYOffset), to: to, arcHeight: arcHeight, controlOffsetX: controlOffsetX, size: 15.w + ((variant % 3) * 2.w), duration: Duration(milliseconds: 760 + (variant * 70)), rotationAmplitude: 0.12 + (variant * 0.025), ), ); }); } void _removeHeartFlight(int id) { if (!mounted) { return; } setState(() { _activeFlights.removeWhere((flight) => flight.id == id); }); } Widget _buildCpHeartOverlay() { if (_activeFlights.isEmpty) { return const SizedBox.shrink(); } return Stack( children: _activeFlights .map( (flight) => _CpHeartFlightWidget( key: ValueKey(flight.id), data: flight, onCompleted: () => _removeHeartFlight(flight.id), ), ) .toList(), ); } Widget _buildCpAdjacencyOverlay() { if (_cpAdjacentPairs.isEmpty) { return const SizedBox.shrink(); } return Stack( children: _cpAdjacentPairs.map((pair) { final Offset? from = _seatCenters[pair.fromIndex]; final Offset? to = _seatCenters[pair.toIndex]; final Size? fromSize = _seatSizes[pair.fromIndex]; final Size? toSize = _seatSizes[pair.toIndex]; if (from == null || to == null || fromSize == null || toSize == null) { return const SizedBox.shrink(); } const double width = 94; const double height = 62; final double bottomLine = math.max( from.dy + fromSize.height / 2, to.dy + toSize.height / 2, ); final double left = ((from.dx + to.dx) / 2) - width.w / 2; final double top = bottomLine - (height.w * 0.58); return Positioned( key: ValueKey( 'cp-adj-${pair.fromIndex}-${pair.toIndex}-${pair.micEffectLevel}', ), left: left, top: top, child: SizedBox( width: width.w, height: height.w, child: SVGAHeadwearWidget( resource: _cpAdjacencyResourceForLevel(pair.micEffectLevel), width: width.w, height: height.w, loops: 0, clearsAfterStop: false, ), ), ); }).toList(), ); } _buildSeat5() { return Container( margin: EdgeInsets.only(top: 3.w, left: 10.w, right: 10.w), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Row( children: [ Expanded(flex: 1, child: SeatItem(index: 0)), Expanded(flex: 1, child: SeatItem(index: 1)), Expanded(flex: 1, child: SeatItem(index: 2)), Expanded(flex: 1, child: SeatItem(index: 3)), Expanded(flex: 1, child: SeatItem(index: 4)), ], ), ], ), ); } _buildSeat10() { return Container( margin: EdgeInsets.only(top: 3.w, left: 10.w, right: 10.w), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Row( children: [ Expanded(flex: 1, child: SeatItem(index: 0)), Expanded(flex: 1, child: SeatItem(index: 1)), Expanded(flex: 1, child: SeatItem(index: 2)), Expanded(flex: 1, child: SeatItem(index: 3)), Expanded(flex: 1, child: SeatItem(index: 4)), ], ), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Expanded(flex: 1, child: SeatItem(index: 5)), Expanded(flex: 1, child: SeatItem(index: 6)), Expanded(flex: 1, child: SeatItem(index: 7)), Expanded(flex: 1, child: SeatItem(index: 8)), Expanded(flex: 1, child: SeatItem(index: 9)), ], ), ], ), ); } _buildSeat15() { return Container( margin: EdgeInsets.only(top: 3.w, left: 10.w, right: 10.w), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Row( children: [ Expanded(flex: 1, child: SeatItem(index: 0)), Expanded(flex: 1, child: SeatItem(index: 1)), Expanded(flex: 1, child: SeatItem(index: 2)), Expanded(flex: 1, child: SeatItem(index: 3)), Expanded(flex: 1, child: SeatItem(index: 4)), ], ), Row( children: [ Expanded(flex: 1, child: SeatItem(index: 5)), Expanded(flex: 1, child: SeatItem(index: 6)), Expanded(flex: 1, child: SeatItem(index: 7)), Expanded(flex: 1, child: SeatItem(index: 8)), Expanded(flex: 1, child: SeatItem(index: 9)), ], ), Row( children: [ Expanded(flex: 1, child: SeatItem(index: 10)), Expanded(flex: 1, child: SeatItem(index: 11)), Expanded(flex: 1, child: SeatItem(index: 12)), Expanded(flex: 1, child: SeatItem(index: 13)), Expanded(flex: 1, child: SeatItem(index: 14)), ], ), ], ), ); } _buildSeat20() { return Container( margin: EdgeInsets.only(top: 3.w, left: 10.w, right: 10.w), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Row( children: [ Expanded(flex: 1, child: SeatItem(index: 0)), Expanded(flex: 1, child: SeatItem(index: 1)), Expanded(flex: 1, child: SeatItem(index: 2)), Expanded(flex: 1, child: SeatItem(index: 3)), Expanded(flex: 1, child: SeatItem(index: 4)), ], ), Row( children: [ Expanded(flex: 1, child: SeatItem(index: 5)), Expanded(flex: 1, child: SeatItem(index: 6)), Expanded(flex: 1, child: SeatItem(index: 7)), Expanded(flex: 1, child: SeatItem(index: 8)), Expanded(flex: 1, child: SeatItem(index: 9)), ], ), Row( children: [ Expanded(flex: 1, child: SeatItem(index: 10)), Expanded(flex: 1, child: SeatItem(index: 11)), Expanded(flex: 1, child: SeatItem(index: 12)), Expanded(flex: 1, child: SeatItem(index: 13)), Expanded(flex: 1, child: SeatItem(index: 14)), ], ), Row( children: [ Expanded(flex: 1, child: SeatItem(index: 15)), Expanded(flex: 1, child: SeatItem(index: 16)), Expanded(flex: 1, child: SeatItem(index: 17)), Expanded(flex: 1, child: SeatItem(index: 18)), Expanded(flex: 1, child: SeatItem(index: 19)), ], ), ], ), ); } } class _CpSeatPair { final int fromIndex; final int toIndex; final int micEffectLevel; final bool hasNonMicEffect; const _CpSeatPair({ required this.fromIndex, required this.toIndex, required this.micEffectLevel, required this.hasNonMicEffect, }); @override bool operator ==(Object other) { return other is _CpSeatPair && other.fromIndex == fromIndex && other.toIndex == toIndex && other.micEffectLevel == micEffectLevel && other.hasNonMicEffect == hasNonMicEffect; } @override int get hashCode => Object.hash(fromIndex, toIndex, micEffectLevel, hasNonMicEffect); } class _CpPairGroups { final List<_CpSeatPair> adjacent; final List<_CpSeatPair> remote; const _CpPairGroups({required this.adjacent, required this.remote}); } class _CpHeartFlightData { final int id; final Offset from; final Offset to; final double arcHeight; final double controlOffsetX; final double size; final Duration duration; final double rotationAmplitude; const _CpHeartFlightData({ required this.id, required this.from, required this.to, required this.arcHeight, required this.controlOffsetX, required this.size, required this.duration, required this.rotationAmplitude, }); } class _CpHeartFlightWidget extends StatefulWidget { final _CpHeartFlightData data; final VoidCallback onCompleted; const _CpHeartFlightWidget({ super.key, required this.data, required this.onCompleted, }); @override State<_CpHeartFlightWidget> createState() => _CpHeartFlightWidgetState(); } class _CpHeartFlightWidgetState extends State<_CpHeartFlightWidget> with SingleTickerProviderStateMixin { late final AnimationController _controller; late final Animation _animation; @override void initState() { super.initState(); _controller = AnimationController( vsync: this, duration: widget.data.duration, ); _animation = CurvedAnimation( parent: _controller, curve: Curves.easeOutCubic, ); _controller.addStatusListener((status) { if (status == AnimationStatus.completed) { widget.onCompleted(); } }); _controller.forward(); } @override void dispose() { _controller.dispose(); super.dispose(); } Offset _bezierPoint(double t) { final Offset start = widget.data.from; final Offset end = widget.data.to; final Offset mid = Offset( (start.dx + end.dx) / 2 + widget.data.controlOffsetX, math.min(start.dy, end.dy) - widget.data.arcHeight, ); final double oneMinusT = 1 - t; return Offset( oneMinusT * oneMinusT * start.dx + 2 * oneMinusT * t * mid.dx + t * t * end.dx, oneMinusT * oneMinusT * start.dy + 2 * oneMinusT * t * mid.dy + t * t * end.dy, ); } @override Widget build(BuildContext context) { return AnimatedBuilder( animation: _animation, builder: (context, child) { final double t = _animation.value; final Offset point = _bezierPoint(t); final double scale = t < 0.35 ? 0.62 + (t * 1.05) : 1.0 - ((t - 0.35) * 0.18); final double opacity = t < 0.12 ? t / 0.12 : (t > 0.72 ? 1 - ((t - 0.72) / 0.28) : 1.0); final double rotation = math.sin(t * math.pi) * widget.data.rotationAmplitude; return Positioned( left: point.dx - widget.data.size / 2, top: point.dy - widget.data.size / 2, child: Opacity( opacity: opacity.clamp(0.0, 1.0), child: Transform.rotate( angle: rotation, child: Transform.scale(scale: scale, child: child), ), ), ); }, child: Image.asset( "atu_images/room/at_icon_cp_heart_ap.png", width: widget.data.size, height: widget.data.size, ), ); } }