537 lines
17 KiB
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),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|