diff --git a/admin/src/all.dto.ts b/admin/src/all.dto.ts index cd928c14..14574d04 100644 --- a/admin/src/all.dto.ts +++ b/admin/src/all.dto.ts @@ -350,6 +350,26 @@ export interface UpdateBearItemDataDto { export interface RequestAllBearItemsDto {} +export interface RequestSpinAvailabilityDto {} + +export interface RequestSpinWheelItemsDto {} + +export interface SpinWheelDto {} + +export interface SpinAvailabilityDto { + canSpin: boolean; + remainingCooldownSeconds: number; +} + +export interface SpinWheelItemsDto { + items: BearItemDto[]; +} + +export interface SpinResultDto { + wonItem: BearItemDto; + cooldownSeconds: number; +} + export interface CampusEventDto { id: string; title: string; diff --git a/admin/src/components/ServerApi.tsx b/admin/src/components/ServerApi.tsx index cb57926f..cce351aa 100644 --- a/admin/src/components/ServerApi.tsx +++ b/admin/src/components/ServerApi.tsx @@ -55,6 +55,22 @@ export class ServerApi { return this.send('equipBearItem', data) as Promise; } + requestSpinAvailability(data: dto.RequestSpinAvailabilityDto) { + return this.send('requestSpinAvailability', data) as Promise< + boolean | undefined + >; + } + + requestSpinWheelItems(data: dto.RequestSpinWheelItemsDto) { + return this.send('requestSpinWheelItems', data) as Promise< + number | undefined + >; + } + + spinWheel(data: dto.SpinWheelDto) { + return this.send('spinWheel', data) as Promise; + } + requestAllBearItems(data: dto.RequestAllBearItemsDto) { return this.send('requestAllBearItems', data) as Promise< number | undefined @@ -432,6 +448,23 @@ export class ServerApi { this.socket.on('updatePurchaseResult', data => callback(data)); } + onUpdateSpinAvailabilityData( + callback: (data: dto.SpinAvailabilityDto) => void, + ) { + this.socket.removeAllListeners('updateSpinAvailabilityData'); + this.socket.on('updateSpinAvailabilityData', data => callback(data)); + } + + onUpdateSpinWheelItemsData(callback: (data: dto.SpinWheelItemsDto) => void) { + this.socket.removeAllListeners('updateSpinWheelItemsData'); + this.socket.on('updateSpinWheelItemsData', data => callback(data)); + } + + onUpdateSpinResultData(callback: (data: dto.SpinResultDto) => void) { + this.socket.removeAllListeners('updateSpinResultData'); + this.socket.on('updateSpinResultData', data => callback(data)); + } + onTimerStarted(callback: (data: dto.TimerStartedDto) => void) { this.socket.removeAllListeners('timerStarted'); this.socket.on('timerStarted', data => callback(data)); diff --git a/game/assets/wheel/Pointer.svg b/game/assets/wheel/Pointer.svg new file mode 100644 index 00000000..fccf2053 --- /dev/null +++ b/game/assets/wheel/Pointer.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/game/assets/wheel/close.svg b/game/assets/wheel/close.svg new file mode 100644 index 00000000..c759d4ba --- /dev/null +++ b/game/assets/wheel/close.svg @@ -0,0 +1,3 @@ + + + diff --git a/game/assets/wheel/equipnow.svg b/game/assets/wheel/equipnow.svg new file mode 100644 index 00000000..a3043368 --- /dev/null +++ b/game/assets/wheel/equipnow.svg @@ -0,0 +1,4 @@ + + + + diff --git a/game/assets/wheel/ferris-wheel.svg b/game/assets/wheel/ferris-wheel.svg new file mode 100644 index 00000000..c574b3fd --- /dev/null +++ b/game/assets/wheel/ferris-wheel.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/game/assets/wheel/orangepiece.svg b/game/assets/wheel/orangepiece.svg new file mode 100644 index 00000000..431c2c7c --- /dev/null +++ b/game/assets/wheel/orangepiece.svg @@ -0,0 +1,4 @@ + + + + diff --git a/game/assets/wheel/orangestopped.svg b/game/assets/wheel/orangestopped.svg new file mode 100644 index 00000000..c174a2b3 --- /dev/null +++ b/game/assets/wheel/orangestopped.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/game/assets/wheel/redpiece.svg b/game/assets/wheel/redpiece.svg new file mode 100644 index 00000000..2c4c7198 --- /dev/null +++ b/game/assets/wheel/redpiece.svg @@ -0,0 +1,4 @@ + + + + diff --git a/game/assets/wheel/redstopped.svg b/game/assets/wheel/redstopped.svg new file mode 100644 index 00000000..472ccbef --- /dev/null +++ b/game/assets/wheel/redstopped.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/game/assets/wheel/spinbutton.svg b/game/assets/wheel/spinbutton.svg new file mode 100644 index 00000000..bd264e12 --- /dev/null +++ b/game/assets/wheel/spinbutton.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/game/assets/wheel/spinthewheel.svg b/game/assets/wheel/spinthewheel.svg new file mode 100644 index 00000000..6845bcd0 --- /dev/null +++ b/game/assets/wheel/spinthewheel.svg @@ -0,0 +1,163 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/game/assets/wheel/startajourney.svg b/game/assets/wheel/startajourney.svg new file mode 100644 index 00000000..1e3a88f3 --- /dev/null +++ b/game/assets/wheel/startajourney.svg @@ -0,0 +1,4 @@ + + + + diff --git a/game/assets/wheel/wait.svg b/game/assets/wheel/wait.svg new file mode 100644 index 00000000..457d4ba5 --- /dev/null +++ b/game/assets/wheel/wait.svg @@ -0,0 +1,4 @@ + + + + diff --git a/game/assets/wheel/wheel.png b/game/assets/wheel/wheel.png new file mode 100644 index 00000000..fb64432c Binary files /dev/null and b/game/assets/wheel/wheel.png differ diff --git a/game/lib/api/game_client_api.dart b/game/lib/api/game_client_api.dart index 890905c1..0425d50d 100644 --- a/game/lib/api/game_client_api.dart +++ b/game/lib/api/game_client_api.dart @@ -93,6 +93,21 @@ class GameClientApi { Stream get updatePurchaseResultStream => _updatePurchaseResultController.stream; + final _updateSpinAvailabilityDataController = + StreamController.broadcast(sync: true); + Stream get updateSpinAvailabilityDataStream => + _updateSpinAvailabilityDataController.stream; + + final _updateSpinWheelItemsDataController = + StreamController.broadcast(sync: true); + Stream get updateSpinWheelItemsDataStream => + _updateSpinWheelItemsDataController.stream; + + final _updateSpinResultDataController = + StreamController.broadcast(sync: true); + Stream get updateSpinResultDataStream => + _updateSpinResultDataController.stream; + final _timerStartedController = StreamController.broadcast(sync: true); Stream get timerStartedStream => @@ -258,6 +273,21 @@ class GameClientApi { (data) => _updatePurchaseResultController .add(UpdatePurchaseResultDto.fromJson(data))); + sock.on( + "updateSpinAvailabilityData", + (data) => _updateSpinAvailabilityDataController + .add(SpinAvailabilityDto.fromJson(data))); + + sock.on( + "updateSpinWheelItemsData", + (data) => _updateSpinWheelItemsDataController + .add(SpinWheelItemsDto.fromJson(data))); + + sock.on( + "updateSpinResultData", + (data) => + _updateSpinResultDataController.add(SpinResultDto.fromJson(data))); + sock.on("timerStarted", (data) => _timerStartedController.add(TimerStartedDto.fromJson(data))); diff --git a/game/lib/api/game_client_dto.dart b/game/lib/api/game_client_dto.dart index 123d9f56..bc8b2aad 100644 --- a/game/lib/api/game_client_dto.dart +++ b/game/lib/api/game_client_dto.dart @@ -1063,6 +1063,125 @@ class RequestAllBearItemsDto { RequestAllBearItemsDto(); } +class RequestSpinAvailabilityDto { + Map toJson() { + Map fields = {}; + return fields; + } + + RequestSpinAvailabilityDto.fromJson(Map fields) {} + + void partialUpdate(RequestSpinAvailabilityDto other) {} + + RequestSpinAvailabilityDto(); +} + +class RequestSpinWheelItemsDto { + Map toJson() { + Map fields = {}; + return fields; + } + + RequestSpinWheelItemsDto.fromJson(Map fields) {} + + void partialUpdate(RequestSpinWheelItemsDto other) {} + + RequestSpinWheelItemsDto(); +} + +class SpinWheelDto { + Map toJson() { + Map fields = {}; + return fields; + } + + SpinWheelDto.fromJson(Map fields) {} + + void partialUpdate(SpinWheelDto other) {} + + SpinWheelDto(); +} + +class SpinAvailabilityDto { + Map toJson() { + Map fields = {}; + fields['canSpin'] = canSpin; + fields['remainingCooldownSeconds'] = remainingCooldownSeconds; + return fields; + } + + SpinAvailabilityDto.fromJson(Map fields) { + canSpin = fields["canSpin"]; + remainingCooldownSeconds = fields["remainingCooldownSeconds"]; + } + + void partialUpdate(SpinAvailabilityDto other) { + canSpin = other.canSpin; + remainingCooldownSeconds = other.remainingCooldownSeconds; + } + + SpinAvailabilityDto({ + required this.canSpin, + required this.remainingCooldownSeconds, + }); + + late bool canSpin; + late int remainingCooldownSeconds; +} + +class SpinWheelItemsDto { + Map toJson() { + Map fields = {}; + fields['items'] = items! + .map>((dynamic val) => val!.toJson()) + .toList(); + return fields; + } + + SpinWheelItemsDto.fromJson(Map fields) { + items = fields["items"] + .map((dynamic val) => BearItemDto.fromJson(val)) + .toList(); + } + + void partialUpdate(SpinWheelItemsDto other) { + items = other.items; + } + + SpinWheelItemsDto({ + required this.items, + }); + + late List items; +} + +class SpinResultDto { + Map toJson() { + Map fields = {}; + fields['wonItem'] = wonItem!.toJson(); + fields['cooldownSeconds'] = cooldownSeconds; + return fields; + } + + SpinResultDto.fromJson(Map fields) { + wonItem = BearItemDto.fromJson(fields['wonItem']); + cooldownSeconds = fields["cooldownSeconds"]; + } + + void partialUpdate(SpinResultDto other) { + wonItem = other.wonItem; + cooldownSeconds = other.cooldownSeconds; + } + + SpinResultDto({ + required this.wonItem, + required this.cooldownSeconds, + }); + + late BearItemDto wonItem; + late int cooldownSeconds; +} + class CampusEventDto { Map toJson() { Map fields = {}; diff --git a/game/lib/api/game_server_api.dart b/game/lib/api/game_server_api.dart index 56ece362..97cbeea9 100644 --- a/game/lib/api/game_server_api.dart +++ b/game/lib/api/game_server_api.dart @@ -87,6 +87,15 @@ class GameServerApi { Future equipBearItem(EquipBearItemDto dto) async => await _invokeWithRefresh("equipBearItem", dto.toJson()); + Future requestSpinAvailability(RequestSpinAvailabilityDto dto) async => + await _invokeWithRefresh("requestSpinAvailability", dto.toJson()); + + Future requestSpinWheelItems(RequestSpinWheelItemsDto dto) async => + await _invokeWithRefresh("requestSpinWheelItems", dto.toJson()); + + Future spinWheel(SpinWheelDto dto) async => + await _invokeWithRefresh("spinWheel", dto.toJson()); + Future requestAllBearItems(RequestAllBearItemsDto dto) async => await _invokeWithRefresh("requestAllBearItems", dto.toJson()); diff --git a/game/lib/navigation_page/bottom_navbar.dart b/game/lib/navigation_page/bottom_navbar.dart index 51c74ac0..4582a518 100644 --- a/game/lib/navigation_page/bottom_navbar.dart +++ b/game/lib/navigation_page/bottom_navbar.dart @@ -1,8 +1,10 @@ import 'package:flutter/material.dart'; import 'package:game/api/game_api.dart'; +import 'package:game/api/game_client_dto.dart'; import 'package:game/global_leaderboard/global_leaderboard_widget.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:game/profile/profile_page.dart'; +import 'package:game/profile/spin_the_wheel_page.dart'; import 'package:game/splash_page/splash_page.dart'; import 'package:provider/provider.dart'; import 'package:game/model/onboarding_model.dart'; @@ -13,6 +15,7 @@ import 'package:game/model/challenge_model.dart'; import 'package:game/widgets/bear_mascot_message.dart'; import 'package:game/utils/utility_functions.dart'; import 'package:showcaseview/showcaseview.dart'; +import 'dart:async'; import 'search_filter_home.dart'; /** @@ -57,14 +60,39 @@ class BottomNavBar extends StatefulWidget { class _BottomNavBarState extends State { int _selectedIndex = 0; + bool _canDailySpinNow = false; + + /// While true, keep the middle tab on the wheel even if the server has already + /// set canSpin to false (so the tab does not switch mid-animation). + bool _spinTabLockedUntilResult = false; bool _hasTriggeredStep11 = false; // Prevent multiple showcase triggers bool _hasTriggeredStep12 = false; // Prevent multiple showcase triggers OverlayEntry? _bearOverlayEntry; + StreamSubscription? _spinAvailabilitySub; static const TextStyle optionStyle = TextStyle( fontSize: 30, fontWeight: FontWeight.bold, ); - late final List _widgetOptions; + List _buildWidgetOptions(bool showDailySpinTab) => [ + SearchFilterBar(initialHomeTab: widget.initialHomeTab), + showDailySpinTab + ? SpinTheWheelPage( + onSpinSessionStarted: () { + if (!mounted) return; + setState(() { + _spinTabLockedUntilResult = true; + }); + }, + onSpinResultClosed: () { + if (!mounted) return; + setState(() { + _spinTabLockedUntilResult = false; + }); + }, + ) + : GlobalLeaderboardWidget(), + ProfilePage(), + ]; void _onItemTapped(int index) { setState(() { @@ -75,15 +103,23 @@ class _BottomNavBarState extends State { @override void initState() { super.initState(); - _widgetOptions = [ - SearchFilterBar(initialHomeTab: widget.initialHomeTab), - GlobalLeaderboardWidget(), - ProfilePage(), - ]; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + final client = context.read(); + _spinAvailabilitySub ??= + client.clientApi.updateSpinAvailabilityDataStream.listen((data) { + if (!mounted) return; + setState(() { + _canDailySpinNow = data.canSpin; + }); + }); + client.serverApi?.requestSpinAvailability(RequestSpinAvailabilityDto()); + }); } @override void dispose() { + _spinAvailabilitySub?.cancel(); _removeBearOverlay(); super.dispose(); } @@ -279,6 +315,8 @@ class _BottomNavBarState extends State { final challengeModel = Provider.of(context); final screenWidth = MediaQuery.of(context).size.width; final screenHeight = MediaQuery.of(context).size.height; + final showDailySpinTab = (_canDailySpinNow || _spinTabLockedUntilResult) && + onboarding.step13FinalComplete; // Step 11: Auto-select Profile tab and start showcase (trigger once) if (onboarding.step10HintButtonComplete && @@ -317,6 +355,7 @@ class _BottomNavBarState extends State { // Step 12: Auto-select Leaderboard tab and start showcase (trigger once) if (onboarding.step11ProfileTabComplete && !onboarding.step12LeaderboardTabComplete && + !showDailySpinTab && !_hasTriggeredStep12) { _hasTriggeredStep12 = true; // Prevent re-triggering on rebuild WidgetsBinding.instance.addPostFrameCallback((_) { @@ -383,7 +422,8 @@ class _BottomNavBarState extends State { }); } // Returning the main content page above the navbar [Home, Leaderboard, Profile] - return _widgetOptions.elementAt(_selectedIndex); + return _buildWidgetOptions(showDailySpinTab) + .elementAt(_selectedIndex); }, ), ), @@ -400,7 +440,21 @@ class _BottomNavBarState extends State { ), label: 'Home', ), - _buildLeaderboardTab(onboarding, screenWidth, screenHeight), + showDailySpinTab + ? BottomNavigationBarItem( + icon: SvgPicture.asset( + "assets/wheel/ferris-wheel.svg", + colorFilter: + ColorFilter.mode(Colors.grey, BlendMode.srcIn), + ), + activeIcon: SvgPicture.asset( + "assets/wheel/ferris-wheel.svg", + colorFilter: + ColorFilter.mode(Colors.black, BlendMode.srcIn), + ), + label: 'Daily Spin', + ) + : _buildLeaderboardTab(onboarding, screenWidth, screenHeight), _buildProfileTab(onboarding, screenWidth, screenHeight), ], currentIndex: _selectedIndex, diff --git a/game/lib/profile/profile_page.dart b/game/lib/profile/profile_page.dart index 35a25bdb..37c5b209 100644 --- a/game/lib/profile/profile_page.dart +++ b/game/lib/profile/profile_page.dart @@ -285,7 +285,7 @@ class _ProfilePageState extends State { ), ), ), - // Clothes hanger and settings icons positioned on the right + // Header action icons on the right Positioned( right: screenWidth * 0.05, top: MediaQuery.of(context) diff --git a/game/lib/profile/spin_the_wheel_page.dart b/game/lib/profile/spin_the_wheel_page.dart new file mode 100644 index 00000000..9d1b2870 --- /dev/null +++ b/game/lib/profile/spin_the_wheel_page.dart @@ -0,0 +1,559 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:game/api/game_api.dart'; +import 'package:game/api/game_client_dto.dart'; +import 'package:provider/provider.dart'; + +class SpinTheWheelPage extends StatefulWidget { + const SpinTheWheelPage({ + super.key, + this.onSpinSessionStarted, + this.onSpinResultClosed, + }); + + /// Fired as soon as the user starts a spin, before the server marks them on cooldown. + final VoidCallback? onSpinSessionStarted; + + /// After the result dialog closes (Equip Now or X), or if the spin errors. + final VoidCallback? onSpinResultClosed; + + @override + State createState() => _SpinTheWheelPageState(); +} + +class _SpinTheWheelPageState extends State + with SingleTickerProviderStateMixin { + List _wheelItems = []; + bool _canSpin = false; + int _remainingCooldownSeconds = 0; + bool _isLoading = true; + bool _isSpinning = false; + + late final AnimationController _spinController; + late Animation _spinAnimation; + double _currentRotation = 0; + + StreamSubscription? _availabilitySub; + StreamSubscription? _wheelItemsSub; + StreamSubscription? _spinResultSub; + StreamSubscription? _errorSub; + Timer? _countdownTimer; + + final _spinResultCompleter = StreamController.broadcast(); + + @override + void initState() { + super.initState(); + _spinController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 4200), + ); + _spinAnimation = AlwaysStoppedAnimation(0); + + WidgetsBinding.instance.addPostFrameCallback((_) { + _initStreamsAndLoad(); + }); + } + + @override + void dispose() { + _availabilitySub?.cancel(); + _wheelItemsSub?.cancel(); + _spinResultSub?.cancel(); + _errorSub?.cancel(); + _countdownTimer?.cancel(); + _spinResultCompleter.close(); + _spinController.dispose(); + super.dispose(); + } + + void _initStreamsAndLoad() { + final apiClient = context.read(); + final server = apiClient.serverApi; + if (server == null) return; + + _availabilitySub ??= + apiClient.clientApi.updateSpinAvailabilityDataStream.listen((data) { + setState(() { + _canSpin = data.canSpin; + _remainingCooldownSeconds = data.remainingCooldownSeconds; + }); + _startOrStopCountdown(); + }); + + _wheelItemsSub ??= + apiClient.clientApi.updateSpinWheelItemsDataStream.listen((data) { + setState(() { + _wheelItems = data.items.take(8).toList(); + _isLoading = false; + }); + }); + + _spinResultSub ??= apiClient.clientApi.updateSpinResultDataStream.listen(( + data, + ) { + _spinResultCompleter.add(data); + }); + + _errorSub ??= apiClient.clientApi.updateErrorDataStream.listen((data) { + if (!mounted || data.message.isEmpty) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(data.message)), + ); + }); + + server.requestSpinAvailability(RequestSpinAvailabilityDto()); + server.requestSpinWheelItems(RequestSpinWheelItemsDto()); + } + + void _startOrStopCountdown() { + _countdownTimer?.cancel(); + if (_canSpin || _remainingCooldownSeconds <= 0) return; + _countdownTimer = Timer.periodic(const Duration(seconds: 1), (timer) { + if (!mounted) { + timer.cancel(); + return; + } + if (_remainingCooldownSeconds <= 0) { + timer.cancel(); + setState(() { + _canSpin = true; + }); + return; + } + setState(() { + _remainingCooldownSeconds -= 1; + }); + }); + } + + String _formatDuration(int seconds) { + final duration = Duration(seconds: seconds); + final hours = duration.inHours.toString().padLeft(2, '0'); + final minutes = (duration.inMinutes % 60).toString().padLeft(2, '0'); + final secs = (duration.inSeconds % 60).toString().padLeft(2, '0'); + return '$hours:$minutes:$secs'; + } + + Future _spin() async { + if (_isSpinning || !_canSpin || _wheelItems.isEmpty) return; + + final apiClient = context.read(); + final server = apiClient.serverApi; + if (server == null) return; + + setState(() { + _isSpinning = true; + }); + widget.onSpinSessionStarted?.call(); + + // Immediate visual feedback on tap so the wheel starts moving right away. + await _animateByRadians( + radians: 2 * math.pi * 1.5, + durationMs: 700, + curve: Curves.easeIn, + ); + + server.spinWheel(SpinWheelDto()); + + SpinResultDto result; + try { + result = await _spinResultCompleter.stream.first.timeout( + const Duration(seconds: 8), + ); + } catch (_) { + if (!mounted) return; + setState(() { + _isSpinning = false; + }); + widget.onSpinResultClosed?.call(); + context + .read() + .serverApi + ?.requestSpinAvailability(RequestSpinAvailabilityDto()); + return; + } + + final wonIndex = + _wheelItems.indexWhere((item) => item.id == result.wonItem.id); + if (wonIndex < 0) { + setState(() { + _wheelItems = [result.wonItem, ..._wheelItems].take(8).toList(); + }); + } + final targetIndex = + _wheelItems.indexWhere((item) => item.id == result.wonItem.id); + await _animateToIndex(targetIndex < 0 ? 0 : targetIndex); + + if (!mounted) return; + setState(() { + _isSpinning = false; + _canSpin = false; + _remainingCooldownSeconds = result.cooldownSeconds; + }); + _startOrStopCountdown(); + _showWinDialog(result.wonItem); + } + + Future _animateByRadians({ + required double radians, + required int durationMs, + required Curve curve, + }) async { + final finalRotation = _currentRotation + radians; + _spinController.duration = Duration(milliseconds: durationMs); + _spinAnimation = Tween( + begin: _currentRotation, + end: finalRotation, + ).animate(CurvedAnimation(parent: _spinController, curve: curve)); + + setState(() {}); + await _spinController.forward(from: 0); + _currentRotation = finalRotation; + } + + Future _animateToIndex(int index) async { + final segmentCount = _wheelItems.isEmpty ? 8 : _wheelItems.length; + final segmentAngle = (2 * math.pi) / segmentCount; + final targetOffset = (2 * math.pi - (index * segmentAngle)) % (2 * math.pi); + final currentOffset = _currentRotation % (2 * math.pi); + final delta = + (targetOffset - currentOffset + (2 * math.pi)) % (2 * math.pi); + final finalRotation = _currentRotation + (6 * 2 * math.pi) + delta; + + _spinController.duration = const Duration(milliseconds: 4200); + _spinAnimation = Tween( + begin: _currentRotation, + end: finalRotation, + ).animate( + CurvedAnimation(parent: _spinController, curve: Curves.easeOutCubic), + ); + + setState(() {}); + await _spinController.forward(from: 0); + _currentRotation = finalRotation; + } + + void _showWinDialog(BearItemDto wonItem) { + void onDismissed() { + if (!mounted) return; + widget.onSpinResultClosed?.call(); + context + .read() + .serverApi + ?.requestSpinAvailability(RequestSpinAvailabilityDto()); + } + + showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) { + return Dialog( + shape: + RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + child: Stack( + clipBehavior: Clip.none, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 28, 20, 20), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + 'You Won', + style: + TextStyle(fontSize: 24, fontWeight: FontWeight.w700), + ), + Text( + wonItem.name.toUpperCase(), + style: const TextStyle( + fontSize: 28, + fontWeight: FontWeight.w900, + color: Color(0xFFED5656), + ), + ), + const SizedBox(height: 16), + SizedBox( + width: 90, + height: 90, + child: Image.asset('assets/${wonItem.assetKey}.png', + fit: BoxFit.contain), + ), + const SizedBox(height: 20), + SizedBox( + width: double.infinity, + height: 52, + child: GestureDetector( + onTap: () { + context.read().serverApi?.equipBearItem( + EquipBearItemDto( + slot: wonItem.slot, itemId: wonItem.id), + ); + Navigator.pop(dialogContext); + }, + child: SvgPicture.asset( + 'assets/wheel/equipnow.svg', + fit: BoxFit.contain, + ), + ), + ), + ], + ), + ), + Positioned( + right: 4, + top: 4, + child: IconButton( + onPressed: () => Navigator.pop(dialogContext), + icon: SvgPicture.asset( + 'assets/wheel/close.svg', + width: 24, + height: 24, + ), + ), + ), + ], + ), + ); + }, + ).then((_) => onDismissed()); + } + + @override + Widget build(BuildContext context) { + final wheelItems = _wheelItems; + + return Scaffold( + backgroundColor: const Color(0xFFFFF5EA), + appBar: AppBar( + backgroundColor: const Color(0xFFED5656), + foregroundColor: Colors.white, + title: const Text('Spin the Wheel'), + ), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), + child: Column( + children: [ + if (_isLoading) + const Expanded( + child: Center(child: CircularProgressIndicator())) + else if (wheelItems.isEmpty) + Expanded( + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + 'No wardrobe items available yet.', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 12), + ElevatedButton( + onPressed: () { + context + .read() + .serverApi + ?.requestSpinWheelItems( + RequestSpinWheelItemsDto(), + ); + }, + child: const Text('Refresh Items'), + ), + ], + ), + ), + ) + else + Expanded( + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SvgPicture.asset( + 'assets/wheel/spinthewheel.svg', + height: 120, + ), + const SizedBox(height: 6), + Padding( + padding: const EdgeInsets.only(top: 56, bottom: 0), + child: Stack( + clipBehavior: Clip.none, + alignment: Alignment.center, + children: [ + AnimatedBuilder( + animation: _spinAnimation, + builder: (context, child) { + final a = _spinAnimation.value; + return Transform.rotate( + angle: a, + child: _WheelWidget( + items: wheelItems, + rotation: a, + ), + ); + }, + ), + Positioned( + top: -58, + child: SvgPicture.asset( + 'assets/wheel/Pointer.svg', + height: 64), + ), + ], + ), + ), + ], + ), + ), + ), + const SizedBox(height: 6), + if (_canSpin) + SizedBox( + width: double.infinity, + child: GestureDetector( + onTap: _isSpinning ? null : _spin, + child: Opacity( + opacity: _isSpinning ? 0.6 : 1, + child: SvgPicture.asset( + 'assets/wheel/spinbutton.svg', + height: 102, + ), + ), + ), + ) + else + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 12), + decoration: BoxDecoration( + color: const Color(0xFF8F8F8F), + borderRadius: BorderRadius.circular(24), + ), + child: Text( + 'Wait ${_formatDuration(_remainingCooldownSeconds)}', + textAlign: TextAlign.center, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + ), + ), + ), + const SizedBox(height: 20), + ], + ), + ), + ), + ); + } +} + +int _segmentIndexAtPointer(int segmentCount, double wheelRotation) { + if (segmentCount <= 0) return 0; + final sa = (2 * math.pi) / segmentCount; + // Pointer at 12 o'clock: bisector of segment j in local is -pi/2 + j*sa; after turn R, j*sa ≡ -R (mod 2pi). + var t = -wheelRotation; + t %= 2 * math.pi; + if (t < 0) t += 2 * math.pi; + return (t / sa + 1e-9).floor() % segmentCount; +} + +class _WheelWidget extends StatelessWidget { + const _WheelWidget({required this.items, required this.rotation}); + + final List items; + final double rotation; + + @override + Widget build(BuildContext context) { + const size = 300.0; + final n = items.length; + final selected = _segmentIndexAtPointer(n, rotation); + return SizedBox( + width: size, + height: size, + child: Stack( + alignment: Alignment.center, + children: [ + CustomPaint( + size: const Size(size, size), + painter: _WheelPainter( + segmentCount: n, + selectedIndex: selected, + ), + ), + ...List.generate(items.length, (index) { + final angle = (-math.pi / 2) + (2 * math.pi * index / items.length); + final radius = size * 0.31; + return Transform.translate( + offset: + Offset(radius * math.cos(angle), radius * math.sin(angle)), + child: SizedBox( + width: 42, + height: 42, + child: Image.asset('assets/${items[index].assetKey}.png', + fit: BoxFit.contain), + ), + ); + }), + ], + ), + ); + } +} + +class _WheelPainter extends CustomPainter { + _WheelPainter({required this.segmentCount, required this.selectedIndex}); + + final int segmentCount; + final int selectedIndex; + + @override + void paint(Canvas canvas, Size size) { + final center = size.center(Offset.zero); + final radius = size.width / 2; + final segmentAngle = (2 * math.pi) / segmentCount; + final fillColors = [ + const Color(0xFFFFAA5B), + const Color(0xFFED5656), + ]; + for (int i = 0; i < segmentCount; i++) { + final path = Path()..moveTo(center.dx, center.dy); + final start = (-math.pi / 2 - segmentAngle / 2) + (i * segmentAngle); + path.arcTo(Rect.fromCircle(center: center, radius: radius), start, + segmentAngle, false); + path.close(); + + final paint = Paint() + ..style = PaintingStyle.fill + ..color = fillColors[i % fillColors.length]; + canvas.drawPath(path, paint); + + if (i == selectedIndex) { + final stroke = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 2.5 + ..color = const Color(0xFFFF0300); + canvas.drawPath(path, stroke); + } + } + + final border = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 6 + ..color = const Color(0xFFED5656); + canvas.drawCircle(center, radius, border); + canvas.drawCircle(center, 10, Paint()..color = const Color(0xFFED5656)); + } + + @override + bool shouldRepaint(covariant _WheelPainter oldDelegate) => + oldDelegate.segmentCount != segmentCount || + oldDelegate.selectedIndex != selectedIndex; +} diff --git a/game/pubspec.yaml b/game/pubspec.yaml index d3e24ef5..48800322 100644 --- a/game/pubspec.yaml +++ b/game/pubspec.yaml @@ -107,6 +107,7 @@ flutter: - assets/buildabear/eyes/ - assets/buildabear/mouth/ - assets/buildabear/accessories/ + - assets/wheel/ - assets/videos/ - assets/audios/ - assets/lottie_animations/ diff --git a/server/prisma/migrations/20260427150616_add_spin_wheel_fields/migration.sql b/server/prisma/migrations/20260427150616_add_spin_wheel_fields/migration.sql new file mode 100644 index 00000000..c449352d --- /dev/null +++ b/server/prisma/migrations/20260427150616_add_spin_wheel_fields/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "User" +ADD COLUMN "lastSpinAt" TIMESTAMP(3); \ No newline at end of file diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index f3e67088..6994ae18 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -116,6 +116,7 @@ model User { eventAttendances EventAttendance[] eventRSVPs EventRSVP[] feedbacks Feedback[] + lastSpinAt DateTime? } model Group { diff --git a/server/src/avatar/avatar.dto.ts b/server/src/avatar/avatar.dto.ts index d26f9621..2b391c0b 100644 --- a/server/src/avatar/avatar.dto.ts +++ b/server/src/avatar/avatar.dto.ts @@ -97,3 +97,23 @@ export interface UpdateBearItemDataDto { } export interface RequestAllBearItemsDto {} + +export interface RequestSpinAvailabilityDto {} + +export interface RequestSpinWheelItemsDto {} + +export interface SpinWheelDto {} + +export interface SpinAvailabilityDto { + canSpin: boolean; + remainingCooldownSeconds: number; +} + +export interface SpinWheelItemsDto { + items: BearItemDto[]; +} + +export interface SpinResultDto { + wonItem: BearItemDto; + cooldownSeconds: number; +} diff --git a/server/src/avatar/avatar.gateway.spec.ts b/server/src/avatar/avatar.gateway.spec.ts index 47c10d4b..298e6e99 100644 --- a/server/src/avatar/avatar.gateway.spec.ts +++ b/server/src/avatar/avatar.gateway.spec.ts @@ -39,6 +39,9 @@ const mockAvatarService = { updateBearItem: jest.fn(), deleteBearItem: jest.fn(), emitUpdateBearItemData: jest.fn(), + getAffectedUserIds: jest.fn(), + getEquippedEntries: jest.fn(), + reEquipDefaults: jest.fn(), getInventory: jest.fn(), getLoadout: jest.fn(), purchaseItem: jest.fn(), @@ -56,6 +59,9 @@ describe('AvatarGateway', () => { beforeEach(async () => { jest.clearAllMocks(); + mockAvatarService.getAffectedUserIds.mockResolvedValue([]); + mockAvatarService.getEquippedEntries.mockResolvedValue([]); + mockAvatarService.reEquipDefaults.mockResolvedValue(undefined); const module: TestingModule = await Test.createTestingModule({ providers: [ diff --git a/server/src/avatar/avatar.gateway.ts b/server/src/avatar/avatar.gateway.ts index 7f70f511..356dd31d 100644 --- a/server/src/avatar/avatar.gateway.ts +++ b/server/src/avatar/avatar.gateway.ts @@ -13,8 +13,11 @@ import { PurchaseBearItemDto, RequestAllBearItemsDto, RequestBearItemsDto, + RequestSpinAvailabilityDto, + RequestSpinWheelItemsDto, RequestUserBearLoadoutDto, RequestUserInventoryDto, + SpinWheelDto, UpdateBearItemDataDto, } from './avatar.dto'; import { AvatarService } from './avatar.service'; @@ -105,6 +108,67 @@ export class AvatarGateway { return true; } + @SubscribeMessage('requestSpinAvailability') + async requestSpinAvailability( + @CallingUser() user: User, + @MessageBody() _data: RequestSpinAvailabilityDto, + ) { + const availability = await this.avatarService.requestSpinAvailability( + user.id, + ); + await this.clientService.sendProtected( + 'updateSpinAvailabilityData', + user, + availability, + ); + return availability.canSpin; + } + + @SubscribeMessage('requestSpinWheelItems') + async requestSpinWheelItems( + @CallingUser() user: User, + @MessageBody() _data: RequestSpinWheelItemsDto, + ) { + const items = await this.avatarService.requestSpinWheelItems(); + await this.clientService.sendProtected('updateSpinWheelItemsData', user, { + items, + }); + return items.length; + } + + @SubscribeMessage('spinWheel') + async spinWheel( + @CallingUser() user: User, + @MessageBody() _data: SpinWheelDto, + ) { + const result = await this.avatarService.spinWheel(user.id); + if (!result) { + await this.clientService.emitErrorData( + user, + 'You cannot spin yet or no items are available.', + ); + return false; + } + + await this.clientService.sendProtected( + 'updateSpinResultData', + user, + result, + ); + await this.clientService.sendProtected('updateSpinAvailabilityData', user, { + canSpin: false, + remainingCooldownSeconds: result.cooldownSeconds, + }); + + const inv = await this.avatarService.getInventory(user.id); + await this.clientService.sendProtected( + 'updateUserInventoryData', + user, + inv, + ); + return true; + } + // ── Admin endpoints ── @SubscribeMessage('requestAllBearItems') diff --git a/server/src/avatar/avatar.service.ts b/server/src/avatar/avatar.service.ts index 50d45664..36f4b7d4 100644 --- a/server/src/avatar/avatar.service.ts +++ b/server/src/avatar/avatar.service.ts @@ -1,9 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { - BearSlot as PrismaBearSlot, - BearItem, - UserBearEquipped, -} from '@prisma/client'; +import { BearItem } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { ClientService } from '../client/client.service'; import { @@ -18,8 +14,13 @@ import { UserInventoryDto, } from './avatar.dto'; +type PrismaBearSlot = BearItem['slot']; + @Injectable() export class AvatarService { + private static readonly SPIN_COOLDOWN_HOURS = 24; + private static readonly WHEEL_ITEM_COUNT = 8; + constructor( private readonly prisma: PrismaService, private readonly clientService: ClientService, @@ -50,11 +51,134 @@ export class AvatarService { private async getUserBalance(userId: string): Promise { const user = await this.prisma.user.findUnique({ where: { id: userId }, - select: { coins: true }, }); return user?.coins ?? 0; } + private toSpinAvailabilityDto( + canSpin: boolean, + remainingCooldownSeconds: number, + ): { canSpin: boolean; remainingCooldownSeconds: number } { + return { + canSpin, + remainingCooldownSeconds: Math.max( + 0, + Math.floor(remainingCooldownSeconds), + ), + }; + } + + private shuffle(items: T[]): T[] { + const cloned = [...items]; + for (let i = cloned.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [cloned[i], cloned[j]] = [cloned[j], cloned[i]]; + } + return cloned; + } + + private getCooldownSeconds(): number { + return AvatarService.SPIN_COOLDOWN_HOURS * 60 * 60; + } + + async requestSpinAvailability(userId: string): Promise<{ + canSpin: boolean; + remainingCooldownSeconds: number; + }> { + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + }); + + if (!user?.lastSpinAt) { + return this.toSpinAvailabilityDto(true, 0); + } + + const nowMs = Date.now(); + const lastSpinMs = user.lastSpinAt.getTime(); + const elapsedSeconds = Math.floor((nowMs - lastSpinMs) / 1000); + const remainingCooldownSeconds = this.getCooldownSeconds() - elapsedSeconds; + if (remainingCooldownSeconds <= 0) { + return this.toSpinAvailabilityDto(true, 0); + } + + return this.toSpinAvailabilityDto(false, remainingCooldownSeconds); + } + + async requestSpinWheelItems(): Promise { + let items = await this.prisma.bearItem.findMany({ + where: { isDefault: false }, + orderBy: [{ slot: 'asc' }, { zIndex: 'asc' }, { name: 'asc' }], + }); + // Fallback for environments where only default items are seeded. + if (items.length === 0) { + items = await this.prisma.bearItem.findMany({ + orderBy: [{ slot: 'asc' }, { zIndex: 'asc' }, { name: 'asc' }], + }); + } + if (items.length === 0) { + return []; + } + + const shuffled = this.shuffle(items); + const selected: BearItem[] = []; + for (let i = 0; i < AvatarService.WHEEL_ITEM_COUNT; i++) { + selected.push(shuffled[i % shuffled.length]); + } + + return selected.map(item => this.toBearItemDto(item)); + } + + async spinWheel(userId: string): Promise<{ + wonItem: BearItemDto; + cooldownSeconds: number; + } | null> { + const availability = await this.requestSpinAvailability(userId); + if (!availability.canSpin) { + return null; + } + + const result = await this.prisma.$transaction(async tx => { + const availableItems = await tx.bearItem.findMany({ + where: { + isDefault: false, + inventories: { + none: { userId }, + }, + }, + }); + + if (availableItems.length === 0) { + return null; + } + + const wonItem = + availableItems[Math.floor(Math.random() * availableItems.length)]; + + await tx.userBearInventory.create({ + data: { + userId, + bearItemId: wonItem.id, + }, + }); + + await tx.user.update({ + where: { id: userId }, + data: { lastSpinAt: new Date() }, + }); + + return wonItem; + }); + + if (!result) { + return null; + } + + return { + wonItem: this.toBearItemDto(result), + cooldownSeconds: this.getCooldownSeconds(), + }; + } + async getInventory(userId: string): Promise { const [inv, balance] = await Promise.all([ this.prisma.userBearInventory.findMany({ diff --git a/server/src/challenge/challenge.service.spec.ts b/server/src/challenge/challenge.service.spec.ts index cfd86e14..f527cf07 100644 --- a/server/src/challenge/challenge.service.spec.ts +++ b/server/src/challenge/challenge.service.spec.ts @@ -6,6 +6,7 @@ import { EventService } from '../event/event.service'; import { ClientService } from '../client/client.service'; import { AchievementService } from '../achievement/achievement.service'; import { CaslAbilityFactory } from '../casl/casl-ability.factory'; +import { UserService } from '../user/user.service'; describe('ChallengeService', () => { let service: ChallengeService; @@ -38,6 +39,10 @@ describe('ChallengeService', () => { useValue: null, provide: CaslAbilityFactory, }, + { + useValue: null, + provide: UserService, + }, ], }).compile(); diff --git a/server/src/client/client.service.ts b/server/src/client/client.service.ts index e8bdc625..3e681068 100644 --- a/server/src/client/client.service.ts +++ b/server/src/client/client.service.ts @@ -26,6 +26,9 @@ import { UpdateAchievementDataDto, } from '../achievement/achievement.dto'; import { + SpinAvailabilityDto, + SpinResultDto, + SpinWheelItemsDto, UpdateBearItemDataDto, UpdateBearItemsDataDto, UpdatePurchaseResultDto, @@ -72,6 +75,9 @@ export type ClientApiDef = { updateUserInventoryData: UpdateUserInventoryDataDto; updateUserBearLoadoutData: UpdateUserBearLoadoutDataDto; updatePurchaseResult: UpdatePurchaseResultDto; + updateSpinAvailabilityData: SpinAvailabilityDto; + updateSpinWheelItemsData: SpinWheelItemsDto; + updateSpinResultData: SpinResultDto; timerStarted: TimerStartedDto; timerExtended: TimerExtendedDto; timerCompleted: TimerCompletedDto;