De-coupled remaining aspects of game into packages
Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
538
packages/wolf_3d_renderer/lib/wolf_3d_renderer.dart
Normal file
538
packages/wolf_3d_renderer/lib/wolf_3d_renderer.dart
Normal file
@@ -0,0 +1,538 @@
|
||||
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 (!MapObject.shouldSpawn(objId, widget.difficulty)) continue;
|
||||
|
||||
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),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user