diff --git a/assets/audio/clear.wav b/assets/audio/clear.wav new file mode 100644 index 0000000..7d2b749 Binary files /dev/null and b/assets/audio/clear.wav differ diff --git a/assets/audio/combo.wav b/assets/audio/combo.wav new file mode 100644 index 0000000..93a49cc Binary files /dev/null and b/assets/audio/combo.wav differ diff --git a/assets/audio/lose.wav b/assets/audio/lose.wav new file mode 100644 index 0000000..3e8b847 Binary files /dev/null and b/assets/audio/lose.wav differ diff --git a/assets/audio/place.wav b/assets/audio/place.wav new file mode 100644 index 0000000..75ac1b0 Binary files /dev/null and b/assets/audio/place.wav differ diff --git a/assets/audio/win.wav b/assets/audio/win.wav new file mode 100644 index 0000000..0c7cc04 Binary files /dev/null and b/assets/audio/win.wav differ diff --git a/lib/services/audio_service.dart b/lib/services/audio_service.dart new file mode 100644 index 0000000..6ab8a6a --- /dev/null +++ b/lib/services/audio_service.dart @@ -0,0 +1,42 @@ +import 'package:audioplayers/audioplayers.dart'; + +enum Sfx { place, clear, combo, win, lose } + +/// Fire-and-forget SFX. A small player pool avoids cutting sounds off when +/// effects overlap (place + clear on the same move). +class AudioService { + AudioService({this.enabled = true}); + + bool enabled; + + static const _files = { + Sfx.place: 'audio/place.wav', + Sfx.clear: 'audio/clear.wav', + Sfx.combo: 'audio/combo.wav', + Sfx.win: 'audio/win.wav', + Sfx.lose: 'audio/lose.wav', + }; + + final _players = [ + for (var i = 0; i < 3; i++) AudioPlayer()..setReleaseMode(ReleaseMode.stop), + ]; + var _next = 0; + + Future play(Sfx sfx) async { + if (!enabled) return; + final player = _players[_next]; + _next = (_next + 1) % _players.length; + try { + await player.stop(); + await player.play(AssetSource(_files[sfx]!)); + } catch (_) { + // Audio must never break gameplay. + } + } + + void dispose() { + for (final p in _players) { + p.dispose(); + } + } +} diff --git a/lib/state/providers.dart b/lib/state/providers.dart index ff2e4aa..f11cb2a 100644 --- a/lib/state/providers.dart +++ b/lib/state/providers.dart @@ -1,8 +1,15 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../services/audio_service.dart'; import 'game_session_notifier.dart'; final gameSessionProvider = NotifierProvider( GameSessionNotifier.new, ); + +final audioServiceProvider = Provider((ref) { + final service = AudioService(); + ref.onDispose(service.dispose); + return service; +}); diff --git a/lib/ui/screens/game_screen.dart b/lib/ui/screens/game_screen.dart index 9ef9dd5..da3a196 100644 --- a/lib/ui/screens/game_screen.dart +++ b/lib/ui/screens/game_screen.dart @@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../game/engine/game_engine.dart'; import '../../game/models/stage.dart'; import '../../l10n/gen/app_localizations.dart'; +import '../../services/audio_service.dart'; import '../../state/game_session_notifier.dart'; import '../../state/providers.dart'; import '../theme/palette.dart'; @@ -124,8 +125,26 @@ class _GameScreenState extends ConsumerState { } } + void _playSfx(GameViewState? prev, GameViewState? next) { + if (next == null) return; + final audio = ref.read(audioServiceProvider); + if (prev?.fxTick != next.fxTick && next.lastPlacement != null) { + final placement = next.lastPlacement!; + if (placement.linesCleared > 0) { + audio.play(placement.comboStreak >= 2 ? Sfx.combo : Sfx.clear); + } else { + audio.play(Sfx.place); + } + } + if (prev?.phase != next.phase) { + if (next.phase == GamePhase.won) audio.play(Sfx.win); + if (next.phase == GamePhase.lost) audio.play(Sfx.lose); + } + } + @override Widget build(BuildContext context) { + ref.listen(gameSessionProvider, _playSfx); final view = ref.watch(gameSessionProvider); if (view == null) { return const Scaffold(body: Center(child: CircularProgressIndicator())); diff --git a/pubspec.yaml b/pubspec.yaml index 2908df4..98d7eb9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -63,6 +63,9 @@ flutter: # the material Icons class. uses-material-design: true + assets: + - assets/audio/ + # To add assets to your application, add an assets section, like this: # assets: # - images/a_dot_burr.jpeg diff --git a/test/ui/game_screen_golden_test.dart b/test/ui/game_screen_golden_test.dart new file mode 100644 index 0000000..d5aee3e --- /dev/null +++ b/test/ui/game_screen_golden_test.dart @@ -0,0 +1,68 @@ +import 'package:block_seasons/core/rng.dart'; +import 'package:block_seasons/game/engine/piece_generator.dart'; +import 'package:block_seasons/l10n/gen/app_localizations.dart'; +import 'package:block_seasons/game/models/stage.dart'; +import 'package:block_seasons/state/providers.dart'; +import 'package:block_seasons/ui/screens/game_screen.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +final _stage = StageConfig.fromJson({ + 'id': 'golden_stage', + 'seed': 777, + 'moveLimit': 22, + 'preset': [ + {'x': 2, 'y': 2, 't': 'gem'}, + {'x': 5, 'y': 2, 't': 'gem'}, + {'x': 2, 'y': 5, 't': 'gem'}, + {'x': 3, 'y': 7, 't': 'filled', 'c': 1}, + {'x': 4, 'y': 7, 't': 'filled', 'c': 2}, + {'x': 5, 'y': 7, 't': 'filled', 'c': 3}, + {'x': 1, 'y': 6, 't': 'filled', 'c': 4}, + ], + 'objectives': [ + {'type': 'clearGems', 'count': 3}, + ], + 'stars': { + 'two': {'movesLeft': 5}, + 'three': {'movesLeft': 10}, + }, + 'generatorProfile': 'mid', +}); + +void main() { + testWidgets('game screen golden snapshot', (tester) async { + await tester.binding.setSurfaceSize(const Size(390, 844)); // iPhone-ish + final container = ProviderContainer(); + addTearDown(container.dispose); + container + .read(gameSessionProvider.notifier) + .startStage(_stage, generator: PieceGenerator(SeededRng(7))); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: MaterialApp( + debugShowCheckedModeBanner: false, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + theme: ThemeData( + colorScheme: ColorScheme.fromSeed( + seedColor: const Color(0xFF5B7FFF), + brightness: Brightness.dark, + ), + useMaterial3: true, + ), + home: const GameScreen(), + ), + ), + ); + await tester.pumpAndSettle(); + + await expectLater( + find.byType(GameScreen), + matchesGoldenFile('goldens/game_screen.png'), + ); + }); +} diff --git a/test/ui/goldens/game_screen.png b/test/ui/goldens/game_screen.png new file mode 100644 index 0000000..83df2ec Binary files /dev/null and b/test/ui/goldens/game_screen.png differ diff --git a/tool/gen_sfx.dart b/tool/gen_sfx.dart new file mode 100644 index 0000000..01fab93 --- /dev/null +++ b/tool/gen_sfx.dart @@ -0,0 +1,92 @@ +// Synthesizes the game's SFX as 16-bit PCM WAVs so no licensed audio is +// needed. Run after changing any envelope: +// dart run tool/gen_sfx.dart +import 'dart:io'; +import 'dart:math' as math; +import 'dart:typed_data'; + +const sampleRate = 44100; + +void main() { + final outDir = Directory('assets/audio')..createSync(recursive: true); + + _write(outDir, 'place.wav', _tone([(180, 220)], 0.06, gain: 0.5)); + _write(outDir, 'clear.wav', _tone([(440, 880)], 0.16, gain: 0.6)); + _write( + outDir, + 'combo.wav', + _concat([ + _tone([(660, 660)], 0.07, gain: 0.55), + _tone([(880, 880)], 0.07, gain: 0.55), + _tone([(1175, 1175)], 0.1, gain: 0.6), + ])); + _write( + outDir, + 'win.wav', + _concat([ + _tone([(523, 523)], 0.12, gain: 0.55), + _tone([(659, 659)], 0.12, gain: 0.55), + _tone([(784, 784)], 0.12, gain: 0.55), + _tone([(1047, 1047)], 0.3, gain: 0.6), + ])); + _write(outDir, 'lose.wav', _tone([(330, 150)], 0.45, gain: 0.5)); + + stdout.writeln('SFX written to ${outDir.path}'); +} + +/// Sine sweep segments (startHz, endHz) with a soft attack/decay envelope. +Float64List _tone(List<(double, double)> sweeps, double seconds, + {double gain = 0.5}) { + final n = (seconds * sampleRate).round(); + final out = Float64List(n); + var phase = 0.0; + for (var i = 0; i < n; i++) { + final t = i / n; + final seg = sweeps[(t * sweeps.length).floor().clamp(0, sweeps.length - 1)]; + final freq = seg.$1 + (seg.$2 - seg.$1) * t; + phase += 2 * math.pi * freq / sampleRate; + final attack = math.min(1.0, i / (0.005 * sampleRate)); + final decay = math.min(1.0, (n - i) / (0.05 * sampleRate)); + out[i] = math.sin(phase) * gain * attack * decay; + } + return out; +} + +Float64List _concat(List parts) { + final total = parts.fold(0, (sum, p) => sum + p.length); + final out = Float64List(total); + var offset = 0; + for (final p in parts) { + out.setAll(offset, p); + offset += p.length; + } + return out; +} + +void _write(Directory dir, String name, Float64List samples) { + final pcm = Int16List(samples.length); + for (var i = 0; i < samples.length; i++) { + pcm[i] = (samples[i].clamp(-1.0, 1.0) * 32767).round(); + } + final dataSize = pcm.length * 2; + final header = ByteData(44) + ..setUint32(0, 0x52494646, Endian.big) // RIFF + ..setUint32(4, 36 + dataSize, Endian.little) + ..setUint32(8, 0x57415645, Endian.big) // WAVE + ..setUint32(12, 0x666d7420, Endian.big) // fmt + ..setUint32(16, 16, Endian.little) + ..setUint16(20, 1, Endian.little) // PCM + ..setUint16(22, 1, Endian.little) // mono + ..setUint32(24, sampleRate, Endian.little) + ..setUint32(28, sampleRate * 2, Endian.little) + ..setUint16(32, 2, Endian.little) + ..setUint16(34, 16, Endian.little) + ..setUint32(36, 0x64617461, Endian.big) // data + ..setUint32(40, dataSize, Endian.little); + final file = File('${dir.path}/$name'); + final bytes = BytesBuilder() + ..add(header.buffer.asUint8List()) + ..add(pcm.buffer.asUint8List()); + file.writeAsBytesSync(bytes.toBytes()); + stdout.writeln(' $name (${(dataSize / 1024).toStringAsFixed(1)} KB)'); +}