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:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../services/audio_service.dart';
|
||||||
import 'game_session_notifier.dart';
|
import 'game_session_notifier.dart';
|
||||||
|
|
||||||
final gameSessionProvider =
|
final gameSessionProvider =
|
||||||
NotifierProvider<GameSessionNotifier, GameViewState?>(
|
NotifierProvider<GameSessionNotifier, GameViewState?>(
|
||||||
GameSessionNotifier.new,
|
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/engine/game_engine.dart';
|
||||||
import '../../game/models/stage.dart';
|
import '../../game/models/stage.dart';
|
||||||
import '../../l10n/gen/app_localizations.dart';
|
import '../../l10n/gen/app_localizations.dart';
|
||||||
|
import '../../services/audio_service.dart';
|
||||||
import '../../state/game_session_notifier.dart';
|
import '../../state/game_session_notifier.dart';
|
||||||
import '../../state/providers.dart';
|
import '../../state/providers.dart';
|
||||||
import '../theme/palette.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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
ref.listen<GameViewState?>(gameSessionProvider, _playSfx);
|
||||||
final view = ref.watch(gameSessionProvider);
|
final view = ref.watch(gameSessionProvider);
|
||||||
if (view == null) {
|
if (view == null) {
|
||||||
return const Scaffold(body: Center(child: CircularProgressIndicator()));
|
return const Scaffold(body: Center(child: CircularProgressIndicator()));
|
||||||
|
|||||||
@@ -63,6 +63,9 @@ flutter:
|
|||||||
# the material Icons class.
|
# the material Icons class.
|
||||||
uses-material-design: true
|
uses-material-design: true
|
||||||
|
|
||||||
|
assets:
|
||||||
|
- assets/audio/
|
||||||
|
|
||||||
# To add assets to your application, add an assets section, like this:
|
# To add assets to your application, add an assets section, like this:
|
||||||
# assets:
|
# assets:
|
||||||
# - images/a_dot_burr.jpeg
|
# - images/a_dot_burr.jpeg
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
@@ -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<Float64List> parts) {
|
||||||
|
final total = parts.fold<int>(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)');
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user