Files
wolf_dart/packages/wolf_3d_renderer/lib/wolf_3d_renderer.dart

537 lines
17 KiB
Dart

import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:wolf_3d_data_types/wolf_3d_data_types.dart';
import 'package:wolf_3d_engine/wolf_3d_engine.dart';
import 'package:wolf_3d_entities/wolf_3d_entities.dart';
import 'package:wolf_3d_input/wolf_3d_input.dart';
import 'package:wolf_3d_renderer/hud.dart';
import 'package:wolf_3d_renderer/raycast_painter.dart';
import 'package:wolf_3d_renderer/weapon_painter.dart';
class WolfRenderer extends StatefulWidget {
const WolfRenderer(
this.data, {
required this.difficulty,
required this.startingEpisode,
super.key,
});
final WolfensteinData data;
final Difficulty difficulty;
final int startingEpisode;
@override
State<WolfRenderer> createState() => _WolfRendererState();
}
class _WolfRendererState extends State<WolfRenderer>
with SingleTickerProviderStateMixin {
final InputManager inputManager = InputManager();
final DoorManager doorManager = DoorManager();
final PushwallManager pushwallManager = PushwallManager();
late Ticker _gameLoop;
final FocusNode _focusNode = FocusNode();
late Level currentLevel;
late WolfLevel activeLevel;
final double fov = math.pi / 3;
late Player player;
bool _isLoading = true;
double damageFlashOpacity = 0.0;
late int _currentEpisodeIndex;
late int _currentLevelIndex;
int? _returnLevelIndex;
List<Entity> entities = [];
@override
void initState() {
super.initState();
_initGame();
}
Future<void> _initGame() async {
// 1. Setup our starting indices
_currentEpisodeIndex = widget.startingEpisode;
_currentLevelIndex = 0;
// 2. Load the first floor!
_loadLevel();
_gameLoop = createTicker(_tick)..start();
_focusNode.requestFocus();
setState(() {
_isLoading = false;
});
}
void _loadLevel() {
// 1. Clean up the previous level's state
entities.clear();
damageFlashOpacity = 0.0;
// 2. Grab the exact level from our new Episode hierarchy
final episode = widget.data.episodes[_currentEpisodeIndex];
activeLevel = episode.levels[_currentLevelIndex];
// 3. DEEP COPY the wall grid! If we don't do this, destroying walls/doors
// will permanently corrupt the map data in the Wolf3d singleton.
currentLevel = List.generate(64, (y) => List.from(activeLevel.wallGrid[y]));
final Level objectLevel = activeLevel.objectGrid;
// 4. Initialize Managers
doorManager.initDoors(currentLevel);
pushwallManager.initPushwalls(currentLevel, objectLevel);
// 6. Spawn Player and Entities
for (int y = 0; y < 64; y++) {
for (int x = 0; x < 64; x++) {
int objId = objectLevel[y][x];
if (objId >= MapObject.playerNorth && objId <= MapObject.playerWest) {
double spawnAngle = 0.0;
if (objId == MapObject.playerNorth) {
spawnAngle = 3 * math.pi / 2;
} else if (objId == MapObject.playerEast) {
spawnAngle = 0.0;
} else if (objId == MapObject.playerSouth) {
spawnAngle = math.pi / 2;
} else if (objId == MapObject.playerWest) {
spawnAngle = math.pi;
}
player = Player(x: x + 0.5, y: y + 0.5, angle: spawnAngle);
} else {
Entity? newEntity = EntityRegistry.spawn(
objId,
x + 0.5,
y + 0.5,
widget.difficulty,
widget.data.sprites.length,
isSharewareMode: widget.data.version == GameVersion.shareware,
);
if (newEntity != null) entities.add(newEntity);
}
}
}
// 7. Clear non-solid blocks from the collision grid
for (int y = 0; y < 64; y++) {
for (int x = 0; x < 64; x++) {
int id = currentLevel[y][x];
if (!((id >= 1 && id <= 63) || (id >= 90 && id <= 101))) {
currentLevel[y][x] = 0;
}
}
}
_bumpPlayerIfStuck();
debugPrint("Loaded Floor: ${_currentLevelIndex + 1} - ${activeLevel.name}");
}
void _onLevelCompleted({bool isSecretExit = false}) {
setState(() {
final currentEpisode = widget.data.episodes[_currentEpisodeIndex];
if (isSecretExit) {
// Save the next normal map index so we can return to it later
_returnLevelIndex = _currentLevelIndex + 1;
_currentLevelIndex = 9; // Jump to the secret map
debugPrint("Found the Secret Exit!");
} else {
// Are we currently ON the secret map, and need to return?
if (_currentLevelIndex == 9 && _returnLevelIndex != null) {
_currentLevelIndex = _returnLevelIndex!;
_returnLevelIndex = null;
} else {
_currentLevelIndex++; // Normal progression
}
}
// Did we just beat the last map in the episode (Map 9) or the secret map (Map 10)?
if (_currentLevelIndex >= currentEpisode.levels.length ||
_currentLevelIndex > 9) {
debugPrint("Episode Completed! You win!");
Navigator.of(context).pop();
} else {
_loadLevel();
}
});
}
@override
void dispose() {
_gameLoop.dispose();
_focusNode.dispose();
super.dispose();
}
void _bumpPlayerIfStuck() {
int pX = player.x.toInt();
int pY = player.y.toInt();
if (pY < 0 ||
pY >= currentLevel.length ||
pX < 0 ||
pX >= currentLevel[0].length ||
currentLevel[pY][pX] > 0) {
double shortestDist = double.infinity;
Coordinate2D nearestSafeSpot = Coordinate2D(1.5, 1.5);
for (int y = 0; y < currentLevel.length; y++) {
for (int x = 0; x < currentLevel[y].length; x++) {
if (currentLevel[y][x] == 0) {
Coordinate2D safeSpot = Coordinate2D(x + 0.5, y + 0.5);
double dist = safeSpot.distanceTo(player.position);
if (dist < shortestDist) {
shortestDist = dist;
nearestSafeSpot = safeSpot;
}
}
}
}
player.x = nearestSafeSpot.x;
player.y = nearestSafeSpot.y;
}
}
bool _isWalkable(int x, int y) {
if (currentLevel[y][x] == 0) return true;
if (currentLevel[y][x] >= 90) {
return doorManager.isDoorOpenEnough(x, y);
}
return false;
}
// --- ORCHESTRATOR ---
void _tick(Duration elapsed) {
// 1. Process intentions and receive movement vectors
final inputResult = _processInputs(elapsed);
doorManager.update(elapsed);
pushwallManager.update(elapsed, currentLevel);
// 2. Explicit State Updates
player.updateWeaponSwitch();
player.angle += inputResult.dAngle;
// Keep the angle neatly clamped between 0 and 2*PI
if (player.angle < 0) player.angle += 2 * math.pi;
if (player.angle >= 2 * math.pi) player.angle -= 2 * math.pi;
final Coordinate2D validatedPos = _calculateValidatedPosition(
player.position,
inputResult.movement,
);
player.x = validatedPos.x;
player.y = validatedPos.y;
_updateEntities(elapsed);
// Explicit reassignment from a pure(r) function
damageFlashOpacity = _calculateScreenEffects(damageFlashOpacity);
// 3. Combat
player.updateWeapon(
currentTime: elapsed.inMilliseconds,
entities: entities,
isWalkable: _isWalkable,
);
// 4. Render
setState(() {});
}
// Returns a Record containing both movement delta and rotation delta
({Coordinate2D movement, double dAngle}) _processInputs(Duration elapsed) {
inputManager.update();
const double moveSpeed = 0.14;
const double turnSpeed = 0.10;
Coordinate2D movement = const Coordinate2D(0, 0);
double dAngle = 0.0;
if (inputManager.requestedWeapon != null) {
player.requestWeaponSwitch(inputManager.requestedWeapon!);
}
if (inputManager.isFiring) {
player.fire(elapsed.inMilliseconds);
} else {
player.releaseTrigger();
}
// Calculate intended rotation
if (inputManager.isTurningLeft) dAngle -= turnSpeed;
if (inputManager.isTurningRight) dAngle += turnSpeed;
// Calculate intended movement based on CURRENT angle
Coordinate2D forwardVec = Coordinate2D(
math.cos(player.angle),
math.sin(player.angle),
);
if (inputManager.isMovingForward) {
movement += forwardVec * moveSpeed;
}
if (inputManager.isMovingBackward) {
movement -= forwardVec * moveSpeed;
}
if (inputManager.isInteracting) {
// 1. Calculate the tile exactly 1 block in front of the player
int targetX = (player.x + math.cos(player.angle)).toInt();
int targetY = (player.y + math.sin(player.angle)).toInt();
// Ensure we don't check outside the map bounds
if (targetX >= 0 && targetX < 64 && targetY >= 0 && targetY < 64) {
// 2. Check the WALL grid for the physical switch texture
int wallId = currentLevel[targetY][targetX];
if (wallId == MapObject.normalElevatorSwitch) {
// Player hit the switch!
_onLevelCompleted(isSecretExit: false);
return (movement: const Coordinate2D(0, 0), dAngle: 0.0);
} else if (wallId == MapObject.secretElevatorSwitch) {
_onLevelCompleted(isSecretExit: true);
return (movement: const Coordinate2D(0, 0), dAngle: 0.0);
}
// 3. Check the OBJECT grid for invisible floor triggers
// (Some custom maps use these instead of wall switches)
int objId = activeLevel.objectGrid[targetY][targetX];
if (objId == MapObject.normalExitTrigger) {
_onLevelCompleted(isSecretExit: false);
return (movement: movement, dAngle: dAngle);
} else if (objId == MapObject.secretExitTrigger) {
_onLevelCompleted(isSecretExit: true);
return (movement: movement, dAngle: dAngle);
}
}
// 4. If it wasn't an elevator, try opening a door or pushing a wall
doorManager.handleInteraction(player.x, player.y, player.angle);
pushwallManager.handleInteraction(
player.x,
player.y,
player.angle,
currentLevel,
);
}
return (movement: movement, dAngle: dAngle);
}
Coordinate2D _calculateValidatedPosition(
Coordinate2D currentPos,
Coordinate2D movement,
) {
const double margin = 0.3;
double newX = currentPos.x;
double newY = currentPos.y;
// Calculate potential new coordinates
Coordinate2D target = currentPos + movement;
// Validate X (allows sliding along walls)
if (movement.x != 0) {
int checkX = (movement.x > 0)
? (target.x + margin).toInt()
: (target.x - margin).toInt();
if (_isWalkable(checkX, currentPos.y.toInt())) {
newX = target.x;
}
}
// Validate Y
if (movement.y != 0) {
int checkY = (movement.y > 0)
? (target.y + margin).toInt()
: (target.y - margin).toInt();
if (_isWalkable(newX.toInt(), checkY)) {
newY = target.y;
}
}
return Coordinate2D(newX, newY);
}
void _updateEntities(Duration elapsed) {
List<Entity> itemsToRemove = [];
List<Entity> itemsToAdd = []; // NEW: Buffer for dropped items
for (Entity entity in entities) {
if (entity is Enemy) {
// 1. Get Intent (Now passing tryOpenDoor!)
final intent = entity.update(
elapsedMs: elapsed.inMilliseconds,
playerPosition: player.position,
isWalkable: _isWalkable,
tryOpenDoor: doorManager.tryOpenDoor,
onDamagePlayer: (int damage) {
player.takeDamage(damage);
damageFlashOpacity = 0.5;
},
);
// 2. Update Angle
entity.angle = intent.newAngle;
// 3. Resolve Movement
// We NO LONGER use _calculateValidatedPosition here!
// The enemy's internal getValidMovement already did the math perfectly.
entity.x += intent.movement.x;
entity.y += intent.movement.y;
// 4. Handle Item Drops & Score (Matches KillActor in C code)
if (entity.state == EntityState.dead &&
entity.isDying &&
!entity.hasDroppedItem) {
entity.hasDroppedItem = true;
// Map ID 44 is usually the Ammo Clip in the Object Grid/Registry
Entity? droppedAmmo = EntityRegistry.spawn(
MapObject.ammoClip,
entity.x,
entity.y,
widget.difficulty,
widget.data.sprites.length,
);
if (droppedAmmo != null) {
itemsToAdd.add(droppedAmmo);
}
// You will need to add a `bool hasDroppedItem = false;` to your base Enemy class.
if (entity.runtimeType.toString() == 'BrownGuard') {
// Example: Spawn an ammo clip where the guard died
// itemsToAdd.add(Collectible(x: entity.x, y: entity.y, type: CollectibleType.ammoClip));
} else if (entity.runtimeType.toString() == 'Dog') {
// Dogs don't drop items, but maybe they give different points!
}
}
} else if (entity is Collectible) {
if (player.position.distanceTo(entity.position) < 0.5) {
if (player.tryPickup(entity)) {
itemsToRemove.add(entity);
}
}
}
}
// Clean up dead items and add new drops
if (itemsToRemove.isNotEmpty) {
entities.removeWhere((e) => itemsToRemove.contains(e));
}
if (itemsToAdd.isNotEmpty) {
entities.addAll(itemsToAdd);
}
}
// Takes an input and returns a value instead of implicitly changing state
double _calculateScreenEffects(double currentOpacity) {
if (currentOpacity > 0) {
return math.max(0.0, currentOpacity - 0.05);
}
return currentOpacity;
}
@override
Widget build(BuildContext context) {
if (_isLoading) {
return const Center(child: CircularProgressIndicator(color: Colors.teal));
}
return Scaffold(
backgroundColor: Colors.black,
body: KeyboardListener(
focusNode: _focusNode,
autofocus: true,
onKeyEvent: (_) {},
child: Column(
children: [
Expanded(
child: LayoutBuilder(
builder: (context, constraints) {
return Center(
child: AspectRatio(
aspectRatio: 16 / 10,
child: Stack(
children: [
CustomPaint(
size: Size(
constraints.maxWidth,
constraints.maxHeight,
),
painter: RaycasterPainter(
map: currentLevel,
textures: widget.data.walls,
player: player,
fov: fov,
doorOffsets: doorManager.getOffsetsForRenderer(),
entities: entities,
sprites: widget.data.sprites,
activePushwall: pushwallManager.activePushwall,
),
),
Positioned(
bottom: -20,
left: 0,
right: 0,
child: Center(
child: Transform.translate(
offset: Offset(0, player.weaponAnimOffset),
child: SizedBox(
width: 500,
height: 500,
child: CustomPaint(
painter: WeaponPainter(
sprite:
widget.data.sprites[player
.currentWeapon
.getCurrentSpriteIndex(
widget.data.sprites.length,
)],
),
),
),
),
),
),
if (damageFlashOpacity > 0)
Positioned.fill(
child: Container(
color: Colors.red.withValues(
alpha: damageFlashOpacity,
),
),
),
],
),
),
);
},
),
),
Hud(player: player),
],
),
),
);
}
}