feat: Implement fizzle fade transition effects for menus and intros, enhancing visual transitions
Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
@@ -223,6 +223,46 @@ void main() {
|
||||
expect(manager.selectedMainIndex, 0);
|
||||
});
|
||||
|
||||
test('menu transition defaults to normal fade and can opt into fizzle', () {
|
||||
final manager = MenuManager();
|
||||
|
||||
manager.showMainMenu(hasResumableGame: false);
|
||||
manager.startTransition(WolfMenuScreen.difficultySelect);
|
||||
|
||||
expect(manager.transitionEffect, WolfTransitionEffect.normalFade);
|
||||
expect(manager.transitionPhase, WolfTransitionPhase.covering);
|
||||
expect(manager.transitionPhaseProgress, 0.0);
|
||||
expect(manager.activeMenu, WolfMenuScreen.mainMenu);
|
||||
|
||||
manager.tickTransition(MenuManager.transitionDurationMs ~/ 4);
|
||||
expect(manager.transitionPhase, WolfTransitionPhase.covering);
|
||||
expect(manager.transitionPhaseProgress, closeTo(0.5, 0.001));
|
||||
expect(manager.activeMenu, WolfMenuScreen.mainMenu);
|
||||
|
||||
manager.tickTransition(MenuManager.transitionDurationMs ~/ 4);
|
||||
expect(manager.transitionPhase, WolfTransitionPhase.revealing);
|
||||
expect(manager.transitionPhaseProgress, 0.0);
|
||||
expect(manager.activeMenu, WolfMenuScreen.difficultySelect);
|
||||
|
||||
manager.tickTransition(MenuManager.transitionDurationMs ~/ 4);
|
||||
expect(manager.transitionPhase, WolfTransitionPhase.revealing);
|
||||
expect(manager.transitionPhaseProgress, closeTo(0.5, 0.001));
|
||||
|
||||
manager.tickTransition(MenuManager.transitionDurationMs ~/ 4);
|
||||
expect(manager.isTransitioning, isFalse);
|
||||
expect(manager.transitionEffect, WolfTransitionEffect.none);
|
||||
expect(manager.transitionPhase, WolfTransitionPhase.idle);
|
||||
expect(manager.transitionPhaseProgress, 0.0);
|
||||
|
||||
manager.startTransition(
|
||||
WolfMenuScreen.mainMenu,
|
||||
effect: WolfTransitionEffect.fizzleFade,
|
||||
);
|
||||
|
||||
expect(manager.transitionEffect, WolfTransitionEffect.fizzleFade);
|
||||
expect(manager.transitionPhase, WolfTransitionPhase.covering);
|
||||
});
|
||||
|
||||
test('quit selection triggers dedicated quit callback', () {
|
||||
final input = _TestInput();
|
||||
int quitCalls = 0;
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:test/test.dart';
|
||||
import 'package:wolf_3d_dart/src/menu/menu_manager.dart';
|
||||
import 'package:wolf_3d_dart/src/rendering/fizzle_fade.dart';
|
||||
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
|
||||
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
|
||||
import 'package:wolf_3d_dart/wolf_3d_input.dart';
|
||||
import 'package:wolf_3d_dart/wolf_3d_renderer.dart';
|
||||
|
||||
void main() {
|
||||
group('Fizzle fade sequence', () {
|
||||
test('covers every canonical menu pixel exactly once', () {
|
||||
final sequence = FizzleFade.canonicalSequence;
|
||||
|
||||
expect(sequence.length, FizzleFade.canonicalPixelCount);
|
||||
expect(sequence.toSet().length, FizzleFade.canonicalPixelCount);
|
||||
expect(sequence.first, 0);
|
||||
expect(FizzleFade.nextState(FizzleFade.seed), 0x12000);
|
||||
});
|
||||
});
|
||||
|
||||
group('Software renderer transitions', () {
|
||||
test(
|
||||
'default intro fade-in starts fully black and reveals by hold phase',
|
||||
() {
|
||||
final engine = _buildEngine(frameBuffer: FrameBuffer(320, 200));
|
||||
final renderer = SoftwareRenderer();
|
||||
final int black = _rgbToFrameColor(0x000000);
|
||||
|
||||
engine.init();
|
||||
|
||||
final FrameBuffer startFrame = renderer.render(engine);
|
||||
expect(startFrame.pixels.every((pixel) => pixel == black), isTrue);
|
||||
|
||||
engine.menuManager.tickTransition(MenuManager.introFadeDurationMs);
|
||||
|
||||
final FrameBuffer revealedFrame = renderer.render(engine);
|
||||
expect(revealedFrame.pixels.any((pixel) => pixel != black), isTrue);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'fizzle menu transition covers at midpoint and reveals target menu on completion',
|
||||
() {
|
||||
final engine = _buildEngine(frameBuffer: FrameBuffer(320, 200));
|
||||
final baselineEngine = _buildEngine(frameBuffer: FrameBuffer(320, 200));
|
||||
final difficultyEngine = _buildEngine(
|
||||
frameBuffer: FrameBuffer(320, 200),
|
||||
);
|
||||
final renderer = SoftwareRenderer();
|
||||
final int bgColor = _rgbToFrameColor(
|
||||
engine.menuManager.menuBackgroundRgb,
|
||||
);
|
||||
|
||||
engine.init();
|
||||
baselineEngine.init();
|
||||
difficultyEngine.init();
|
||||
|
||||
engine.menuManager.showMainMenu(hasResumableGame: false);
|
||||
baselineEngine.menuManager.showMainMenu(hasResumableGame: false);
|
||||
difficultyEngine.menuManager.beginDifficultySelection();
|
||||
|
||||
final List<int> mainMenuFrame = List<int>.from(
|
||||
renderer.render(baselineEngine).pixels,
|
||||
);
|
||||
|
||||
engine.menuManager.startTransition(
|
||||
WolfMenuScreen.difficultySelect,
|
||||
effect: WolfTransitionEffect.fizzleFade,
|
||||
);
|
||||
engine.menuManager.tickTransition(
|
||||
MenuManager.transitionDurationMs ~/ 4,
|
||||
);
|
||||
final List<int> partialFrame = List<int>.from(
|
||||
renderer.render(engine).pixels,
|
||||
);
|
||||
|
||||
expect(
|
||||
_countDifferentPixels(partialFrame, mainMenuFrame),
|
||||
greaterThan(0),
|
||||
);
|
||||
|
||||
engine.menuManager.tickTransition(
|
||||
MenuManager.transitionDurationMs ~/ 4,
|
||||
);
|
||||
final List<int> midpointFrame = List<int>.from(
|
||||
renderer.render(engine).pixels,
|
||||
);
|
||||
|
||||
expect(midpointFrame.every((pixel) => pixel == bgColor), isTrue);
|
||||
|
||||
engine.menuManager.tickTransition(
|
||||
MenuManager.transitionDurationMs ~/ 2,
|
||||
);
|
||||
final List<int> completedFrame = List<int>.from(
|
||||
renderer.render(engine).pixels,
|
||||
);
|
||||
final List<int> expectedDifficultyFrame = List<int>.from(
|
||||
renderer.render(difficultyEngine).pixels,
|
||||
);
|
||||
|
||||
expect(completedFrame, orderedEquals(expectedDifficultyFrame));
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
WolfEngine _buildEngine({required FrameBuffer frameBuffer}) {
|
||||
return WolfEngine(
|
||||
data: _buildTestData(),
|
||||
difficulty: null,
|
||||
startingEpisode: 0,
|
||||
frameBuffer: frameBuffer,
|
||||
input: _TestInput(),
|
||||
engineAudio: _SilentAudio(),
|
||||
onGameWon: () {},
|
||||
);
|
||||
}
|
||||
|
||||
WolfensteinData _buildTestData() {
|
||||
final wallGrid = _buildGrid();
|
||||
final objectGrid = _buildGrid();
|
||||
|
||||
_fillBoundaries(wallGrid, 2);
|
||||
objectGrid[2][2] = MapObject.playerEast;
|
||||
|
||||
return WolfensteinData(
|
||||
version: GameVersion.retail,
|
||||
dataVersion: DataVersion.unknown,
|
||||
registry: RetailAssetRegistry(),
|
||||
walls: [
|
||||
_solidSprite(1),
|
||||
_solidSprite(1),
|
||||
_solidSprite(2),
|
||||
_solidSprite(2),
|
||||
],
|
||||
sprites: List.generate(436, (_) => _solidSprite(255)),
|
||||
sounds: List.generate(200, (_) => PcmSound(Uint8List(1))),
|
||||
adLibSounds: const [],
|
||||
music: const [],
|
||||
vgaImages: const [],
|
||||
episodes: [
|
||||
Episode(
|
||||
name: 'Episode 1',
|
||||
levels: [
|
||||
WolfLevel(
|
||||
name: 'Level 1',
|
||||
wallGrid: wallGrid,
|
||||
areaGrid: List.generate(64, (_) => List.filled(64, -1)),
|
||||
objectGrid: objectGrid,
|
||||
music: Music.level01,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
class _TestInput extends Wolf3dInput {
|
||||
@override
|
||||
void update() {}
|
||||
}
|
||||
|
||||
class _SilentAudio implements EngineAudio {
|
||||
@override
|
||||
WolfensteinData? activeGame;
|
||||
|
||||
@override
|
||||
Future<void> debugSoundTest() async {}
|
||||
|
||||
@override
|
||||
Future<void> init() async {}
|
||||
|
||||
@override
|
||||
void playLevelMusic(Music music) {}
|
||||
|
||||
@override
|
||||
void playMenuMusic() {}
|
||||
|
||||
@override
|
||||
void playSoundEffect(SoundEffect effect) {}
|
||||
|
||||
@override
|
||||
void playSoundEffectId(int sfxId) {}
|
||||
|
||||
@override
|
||||
void stopMusic() {}
|
||||
|
||||
@override
|
||||
Future<void> stopAllAudio() async {}
|
||||
|
||||
@override
|
||||
void dispose() {}
|
||||
}
|
||||
|
||||
int _countDifferentPixels(List<int> a, List<int> b) {
|
||||
int count = 0;
|
||||
for (int i = 0; i < a.length; i++) {
|
||||
if (a[i] != b[i]) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
int _rgbToFrameColor(int rgb) {
|
||||
final int r = (rgb >> 16) & 0xFF;
|
||||
final int g = (rgb >> 8) & 0xFF;
|
||||
final int b = rgb & 0xFF;
|
||||
return 0xFF000000 | (b << 16) | (g << 8) | r;
|
||||
}
|
||||
|
||||
SpriteMap _buildGrid() => List.generate(64, (_) => List.filled(64, 0));
|
||||
|
||||
void _fillBoundaries(SpriteMap grid, int wallId) {
|
||||
for (int i = 0; i < 64; i++) {
|
||||
grid[0][i] = wallId;
|
||||
grid[63][i] = wallId;
|
||||
grid[i][0] = wallId;
|
||||
grid[i][63] = wallId;
|
||||
}
|
||||
}
|
||||
|
||||
Sprite _solidSprite(int colorIndex) {
|
||||
return Sprite(Uint8List.fromList(List.filled(64 * 64, colorIndex)));
|
||||
}
|
||||
Reference in New Issue
Block a user