Add synthesized SFX and audio wiring
Pure-Dart WAV synthesizer (tool/gen_sfx.dart) generates place/clear/ combo/win/lose effects; AudioService player pool fires on placement, line clears, combo streaks, and phase transitions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 = <AudioPlayer>[
|
||||
for (var i = 0; i < 3; i++) AudioPlayer()..setReleaseMode(ReleaseMode.stop),
|
||||
];
|
||||
var _next = 0;
|
||||
|
||||
Future<void> 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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, GameViewState?>(
|
||||
GameSessionNotifier.new,
|
||||
);
|
||||
|
||||
final audioServiceProvider = Provider<AudioService>((ref) {
|
||||
final service = AudioService();
|
||||
ref.onDispose(service.dispose);
|
||||
return service;
|
||||
});
|
||||
|
||||
@@ -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<GameScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
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<GameViewState?>(gameSessionProvider, _playSfx);
|
||||
final view = ref.watch(gameSessionProvider);
|
||||
if (view == null) {
|
||||
return const Scaffold(body: Center(child: CircularProgressIndicator()));
|
||||
|
||||
Reference in New Issue
Block a user