WIP: Refactoring game engine and entities into packages

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
2026-03-15 15:53:39 +01:00
parent 5f3e3bb823
commit 026e6d8cb4
46 changed files with 645 additions and 116 deletions

View File

@@ -1,28 +0,0 @@
import 'dart:math' as math;
enum CardinalDirection {
east(0.0),
south(math.pi / 2),
west(math.pi),
north(3 * math.pi / 2)
;
final double radians;
const CardinalDirection(this.radians);
/// Helper to decode Wolf3D enemy directional blocks
static CardinalDirection fromEnemyIndex(int index) {
switch (index % 4) {
case 0:
return CardinalDirection.east;
case 1:
return CardinalDirection.north;
case 2:
return CardinalDirection.west;
case 3:
return CardinalDirection.south;
default:
return CardinalDirection.east;
}
}
}

View File

@@ -1,69 +0,0 @@
import 'dart:math' as math;
/// A lightweight, immutable 2D Vector/Coordinate system.
class Coordinate2D implements Comparable<Coordinate2D> {
final double x;
final double y;
const Coordinate2D(this.x, this.y);
/// Returns the angle in radians between this coordinate and [other].
/// Useful for "Look At" logic or determining steering direction.
/// Result is between -pi and pi.
double angleTo(Coordinate2D other) {
return math.atan2(other.y - y, other.x - x);
}
/// Rotates the coordinate around (0,0) by [radians].
Coordinate2D rotate(double radians) {
final cos = math.cos(radians);
final sin = math.sin(radians);
return Coordinate2D(
(x * cos) - (y * sin),
(x * sin) + (y * cos),
);
}
/// Linear Interpolation: Slides between this and [target] by [t] (0.0 to 1.0).
/// Perfect for smooth camera follows or "lerping" an object to a new spot.
Coordinate2D lerp(Coordinate2D target, double t) {
return Coordinate2D(
x + (target.x - x) * t,
y + (target.y - y) * t,
);
}
double get magnitude => math.sqrt(x * x + y * y);
Coordinate2D get normalized {
final m = magnitude;
if (m == 0) return const Coordinate2D(0, 0);
return Coordinate2D(x / m, y / m);
}
double dot(Coordinate2D other) => (x * other.x) + (y * other.y);
double distanceTo(Coordinate2D other) => (this - other).magnitude;
Coordinate2D operator +(Coordinate2D other) =>
Coordinate2D(x + other.x, y + other.y);
Coordinate2D operator -(Coordinate2D other) =>
Coordinate2D(x - other.x, y - other.y);
Coordinate2D operator *(double n) => Coordinate2D(x * n, y * n);
Coordinate2D operator /(double n) => Coordinate2D(x / n, y / n);
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is Coordinate2D && x == other.x && y == other.y;
@override
int get hashCode => Object.hash(x, y);
@override
int compareTo(Coordinate2D other) =>
x != other.x ? x.compareTo(other.x) : y.compareTo(other.y);
@override
String toString() => 'Coordinate2D($x, $y)';
}

View File

@@ -1,12 +0,0 @@
enum Difficulty {
canIPlayDaddy(0, "Can I play, Daddy?"),
dontHurtMe(1, "Don't hurt me."),
bringEmOn(2, "Bring em' on!"),
iAmDeathIncarnate(3, "I am Death incarnate!"),
;
final String title;
final int level;
const Difficulty(this.level, this.title);
}

View File

@@ -1,49 +0,0 @@
import 'package:wolf_dart/features/difficulty/difficulty.dart';
import 'package:wolf_dart/features/entities/entity.dart';
enum CollectibleType { ammo, health, treasure, weapon, key }
class Collectible extends Entity {
final CollectibleType type;
Collectible({
required super.x,
required super.y,
required super.spriteIndex,
required super.mapId,
required this.type,
}) : super(state: EntityState.staticObj);
// Define which Map IDs are actually items you can pick up
static bool isCollectible(int objId) {
return (objId >= 43 && objId <= 44) || // Keys
(objId >= 47 && objId <= 56); // Health, Ammo, Weapons, Treasure, 1-Up
}
static CollectibleType _getType(int objId) {
if (objId == 43 || objId == 44) return CollectibleType.key;
if (objId == 47 || objId == 48) return CollectibleType.health;
if (objId == 49) return CollectibleType.ammo;
if (objId == 50 || objId == 51) return CollectibleType.weapon;
return CollectibleType.treasure; // 52-56
}
static Collectible? trySpawn(
int objId,
double x,
double y,
Difficulty difficulty, {
bool isSharewareMode = false,
}) {
if (isCollectible(objId)) {
return Collectible(
x: x,
y: y,
spriteIndex: objId - 21, // Same VSWAP math as decorations!
mapId: objId,
type: _getType(objId),
);
}
return null;
}
}

View File

@@ -1,58 +0,0 @@
import 'package:wolf_dart/features/difficulty/difficulty.dart';
import 'package:wolf_dart/features/entities/entity.dart';
class Decorative extends Entity {
Decorative({
required super.x,
required super.y,
required super.spriteIndex,
required super.mapId,
super.state = EntityState.staticObj, // Defaults to static
});
// Checks if the Map ID belongs to a standard decoration
static bool isDecoration(int objId) {
// ID 124 is a dead guard in WL1, but an SS guard in WL6.
// However, for spawning purposes, if the SS trySpawn fails,
// we only want to treat it as a decoration if it's not a live actor.
if (objId == 124 || objId == 125) return true;
if (objId >= 23 && objId <= 70) {
// Exclude collectibles defined in MapObject
if ((objId >= 43 && objId <= 44) || (objId >= 47 && objId <= 56)) {
return false;
}
return true;
}
return false;
}
static int getSpriteIndex(int objId) {
if (objId == 124) return 95; // Dead guard sprite index
if (objId == 125) return 96; // Dead Aardwolf/Other body
// Standard decorations are typically offset by 21 in the VSWAP
return objId - 21;
}
static Decorative? trySpawn(
int objId,
double x,
double y,
Difficulty difficulty, {
bool isSharewareMode = false,
}) {
if (isDecoration(objId)) {
return Decorative(
x: x,
y: y,
spriteIndex: getSpriteIndex(objId),
mapId: objId,
state: objId == 124 ? EntityState.dead : EntityState.staticObj,
);
}
// Not a decoration!
return null;
}
}

View File

@@ -1,56 +0,0 @@
enum DoorState { closed, opening, open, closing }
class Door {
final int x;
final int y;
final int mapId; // To differentiate between regular doors and elevator doors
DoorState state = DoorState.closed;
double offset = 0.0;
int openTime = 0; // When did the door fully open?
// How long a door stays open before auto-closing
static const int openDurationMs = 3000;
Door({
required this.x,
required this.y,
required this.mapId,
});
// Returns true if the door state changed this frame (useful for playing sounds later)
bool update(int currentTimeMs) {
bool stateChanged = false;
if (state == DoorState.opening) {
offset += 0.02; // Slide speed
if (offset >= 1.0) {
offset = 1.0;
state = DoorState.open;
openTime = currentTimeMs;
stateChanged = true;
}
} else if (state == DoorState.open) {
if (currentTimeMs - openTime > openDurationMs) {
state = DoorState.closing;
stateChanged = true;
}
} else if (state == DoorState.closing) {
// Note: We don't check for entities blocking the door yet!
offset -= 0.02;
if (offset <= 0.0) {
offset = 0.0;
state = DoorState.closed;
stateChanged = true;
}
}
return stateChanged;
}
void interact() {
if (state == DoorState.closed || state == DoorState.closing) {
state = DoorState.opening;
}
}
}

View File

@@ -1,68 +0,0 @@
import 'dart:math' as math;
import 'package:wolf_3d_data_types/wolf_3d_data_types.dart';
import 'package:wolf_dart/features/entities/door.dart';
class DoorManager {
// Key is '$x,$y'
final Map<String, Door> doors = {};
void initDoors(Sprite wallGrid) {
doors.clear();
for (int y = 0; y < wallGrid.length; y++) {
for (int x = 0; x < wallGrid[y].length; x++) {
int id = wallGrid[y][x];
if (id >= 90) {
// Assuming 90+ are doors based on your previous code
doors['$x,$y'] = Door(x: x, y: y, mapId: id);
}
}
}
}
void update(Duration elapsed) {
for (final door in doors.values) {
door.update(elapsed.inMilliseconds);
}
}
void handleInteraction(double playerX, double playerY, double playerAngle) {
int targetX = (playerX + math.cos(playerAngle)).toInt();
int targetY = (playerY + math.sin(playerAngle)).toInt();
String key = '$targetX,$targetY';
if (doors.containsKey(key)) {
doors[key]!.interact();
}
}
// Helper method for the raycaster
Map<String, double> getOffsetsForRenderer() {
Map<String, double> offsets = {};
for (var entry in doors.entries) {
if (entry.value.offset > 0.0) {
offsets[entry.key] = entry.value.offset;
}
}
return offsets;
}
void tryOpenDoor(int x, int y) {
String key = '$x,$y';
if (doors.containsKey(key)) {
// If it's closed or closing, interact() will usually start it opening
if (doors[key]!.offset == 0.0) {
doors[key]!.interact();
}
}
}
bool isDoorOpenEnough(int x, int y) {
String key = '$x,$y';
if (doors.containsKey(key)) {
// 0.7 offset means 70% open, similar to the original engine's check
return doors[key]!.offset > 0.7;
}
return false; // Not a door we manage
}
}

View File

@@ -1,155 +0,0 @@
import 'dart:math' as math;
import 'package:wolf_dart/classes/coordinate_2d.dart';
import 'package:wolf_dart/features/difficulty/difficulty.dart';
import 'package:wolf_dart/features/entities/enemies/enemy.dart';
import 'package:wolf_dart/features/entities/entity.dart';
import 'package:wolf_dart/features/entities/map_objects.dart';
class HansGrosse extends Enemy {
static const double speed = 0.04;
static const int _baseSprite = 291;
bool _hasFiredThisCycle = false;
HansGrosse({
required super.x,
required super.y,
required super.angle,
required super.mapId,
required Difficulty difficulty,
}) : super(
spriteIndex: _baseSprite,
state: EntityState.idle,
) {
// Boss health scales heavily with difficulty
health = switch (difficulty.level) {
0 => 850,
1 => 950,
2 => 1050,
_ => 1200,
};
damage = 20; // Dual chainguns hit hard!
}
static HansGrosse? trySpawn(
int objId,
double x,
double y,
Difficulty difficulty, {
bool isSharewareMode = false,
}) {
if (objId == MapObject.bossHansGrosse) {
return HansGrosse(
x: x,
y: y,
angle: MapObject.getAngle(objId),
mapId: objId,
difficulty: difficulty,
);
}
return null;
}
@override
void takeDamage(int amount, int currentTime) {
if (state == EntityState.dead) return;
health -= amount;
lastActionTime = currentTime;
if (health <= 0) {
state = EntityState.dead;
isDying = true;
}
// Note: Bosses do NOT have a pain state! They never flinch.
}
@override
({Coordinate2D movement, double newAngle}) update({
required int elapsedMs,
required Coordinate2D playerPosition,
required bool Function(int x, int y) isWalkable,
required void Function(int damage) onDamagePlayer,
required void Function(int x, int y) tryOpenDoor,
}) {
Coordinate2D movement = const Coordinate2D(0, 0);
// Bosses lack directional sprites, they always look straight at the player
double newAngle = position.angleTo(playerPosition);
checkWakeUp(
elapsedMs: elapsedMs,
playerPosition: playerPosition,
isWalkable: isWalkable,
baseReactionMs: 50,
);
double distance = position.distanceTo(playerPosition);
switch (state) {
case EntityState.idle:
spriteIndex = _baseSprite;
break;
case EntityState.patrolling:
if (distance > 1.5) {
double moveX = math.cos(newAngle) * speed;
double moveY = math.sin(newAngle) * speed;
movement = getValidMovement(
Coordinate2D(moveX, moveY),
isWalkable,
tryOpenDoor,
);
}
int walkFrame = (elapsedMs ~/ 150) % 4;
spriteIndex = (_baseSprite + 1) + walkFrame;
if (distance < 8.0 && elapsedMs - lastActionTime > 1000) {
if (hasLineOfSight(playerPosition, isWalkable)) {
state = EntityState.attacking;
lastActionTime = elapsedMs;
_hasFiredThisCycle = false;
}
}
break;
case EntityState.attacking:
int timeShooting = elapsedMs - lastActionTime;
if (timeShooting < 150) {
spriteIndex = _baseSprite + 5; // Aiming
} else if (timeShooting < 300) {
spriteIndex = _baseSprite + 6; // Firing
if (!_hasFiredThisCycle) {
onDamagePlayer(damage);
_hasFiredThisCycle = true;
}
} else if (timeShooting < 450) {
spriteIndex = _baseSprite + 7; // Recoil
} else {
state = EntityState.patrolling;
lastActionTime = elapsedMs;
}
break;
case EntityState.dead:
if (isDying) {
int deathFrame = (elapsedMs - lastActionTime) ~/ 150;
if (deathFrame < 4) {
spriteIndex = (_baseSprite + 8) + deathFrame;
} else {
spriteIndex = _baseSprite + 11; // Final dead frame
isDying = false;
}
} else {
spriteIndex = _baseSprite + 11;
}
break;
default:
break;
}
return (movement: movement, newAngle: newAngle);
}
}

View File

@@ -1,91 +0,0 @@
import 'dart:math' as math;
import 'package:wolf_dart/classes/coordinate_2d.dart';
import 'package:wolf_dart/features/entities/enemies/enemy.dart';
import 'package:wolf_dart/features/entities/entity.dart';
class Dog extends Enemy {
static const double speed = 0.05;
bool _hasBittenThisCycle = false;
static EnemyType get type => EnemyType.dog;
Dog({
required super.x,
required super.y,
required super.angle,
required super.mapId,
}) : super(
spriteIndex: type.spriteBaseIdx,
state: EntityState.idle,
) {
health = 1;
damage = 5;
}
@override
({Coordinate2D movement, double newAngle}) update({
required int elapsedMs,
required Coordinate2D playerPosition,
required bool Function(int x, int y) isWalkable,
required void Function(int damage) onDamagePlayer,
required void Function(int x, int y) tryOpenDoor,
}) {
Coordinate2D movement = const Coordinate2D(0, 0);
double newAngle = angle;
checkWakeUp(
elapsedMs: elapsedMs,
playerPosition: playerPosition,
isWalkable: isWalkable,
);
double distance = position.distanceTo(playerPosition);
double angleToPlayer = position.angleTo(playerPosition);
if (state != EntityState.idle && state != EntityState.dead) {
newAngle = angleToPlayer;
}
double diff = angleToPlayer - newAngle;
while (diff <= -math.pi) {
diff += 2 * math.pi;
}
while (diff > math.pi) {
diff -= 2 * math.pi;
}
EnemyAnimation currentAnim = switch (state) {
EntityState.patrolling => EnemyAnimation.walking,
EntityState.attacking => EnemyAnimation.attacking,
EntityState.dead => isDying ? EnemyAnimation.dying : EnemyAnimation.dead,
_ => EnemyAnimation.idle,
};
spriteIndex = type.getSpriteFromAnimation(
animation: currentAnim,
elapsedMs: elapsedMs,
lastActionTime: lastActionTime,
angleDiff: diff,
);
if (state == EntityState.patrolling && distance < 1.0) {
state = EntityState.attacking;
lastActionTime = elapsedMs;
_hasBittenThisCycle = false;
}
if (state == EntityState.attacking) {
int time = elapsedMs - lastActionTime;
if (time >= 200 && !_hasBittenThisCycle) {
onDamagePlayer(damage);
_hasBittenThisCycle = true;
} else if (time >= 400) {
state = EntityState.patrolling;
lastActionTime = elapsedMs;
}
}
return (movement: movement, newAngle: newAngle);
}
}

View File

@@ -1,352 +0,0 @@
import 'dart:math' as math;
import 'package:wolf_dart/classes/coordinate_2d.dart';
import 'package:wolf_dart/features/difficulty/difficulty.dart';
import 'package:wolf_dart/features/entities/enemies/dog.dart';
import 'package:wolf_dart/features/entities/enemies/guard.dart';
import 'package:wolf_dart/features/entities/enemies/mutant.dart';
import 'package:wolf_dart/features/entities/enemies/officer.dart';
import 'package:wolf_dart/features/entities/enemies/ss.dart';
import 'package:wolf_dart/features/entities/entity.dart';
import 'package:wolf_dart/features/entities/map_objects.dart';
enum EnemyAnimation {
idle,
walking,
attacking,
pain,
dying,
dead,
}
enum EnemyType {
guard(mapBaseId: 108, spriteBaseIdx: 50),
dog(mapBaseId: 216, spriteBaseIdx: 99),
ss(mapBaseId: 180, spriteBaseIdx: 138),
mutant(mapBaseId: 252, spriteBaseIdx: 187),
officer(mapBaseId: 144, spriteBaseIdx: 238),
;
final int mapBaseId;
final int spriteBaseIdx;
const EnemyType({
required this.mapBaseId,
required this.spriteBaseIdx,
});
/// Helper to check if a specific TED5 Map ID belongs to this enemy
bool claimsMapId(int id) => id >= mapBaseId && id <= mapBaseId + 35;
/// Helper to find which EnemyType a given Map ID belongs to
static EnemyType? fromMapId(int id) {
for (final type in EnemyType.values) {
if (type.claimsMapId(id)) return type;
}
return null;
}
bool claimsSpriteIndex(int index) {
return switch (this) {
// Walk, Action, & Death: 50-98
EnemyType.guard => index >= 50 && index <= 98,
// Walk, Action, & Death: 99-137
EnemyType.dog => index >= 99 && index <= 137,
// Walk, Action, & Death: 138-186
EnemyType.ss => index >= 138 && index <= 186,
// Walk, Action, & Death: 187-237
EnemyType.mutant => index >= 187 && index <= 237,
// Walk, Action, & Death: 238-287
EnemyType.officer => index >= 238 && index <= 287,
};
}
/// Returns the current animation state for a given sprite index.
/// Returns null if the sprite index does not belong to this enemy.
EnemyAnimation? getAnimationFromSprite(int spriteIndex) {
if (!claimsSpriteIndex(spriteIndex)) return null;
// By working with offsets, we don't have to hardcode the 100+ sprite indices!
int offset = spriteIndex - spriteBaseIdx;
// All standard enemies use offsets 0-7 for their 8 directional Idle frames
if (offset >= 0 && offset <= 7) return EnemyAnimation.idle;
// The action frames vary slightly depending on the enemy type
return switch (this) {
EnemyType.guard || EnemyType.ss => switch (offset) {
>= 8 && <= 39 => EnemyAnimation.walking, // 4 frames * 8 directions
>= 40 && <= 42 => EnemyAnimation.attacking, // Aim, Fire, Recoil
43 => EnemyAnimation.pain,
>= 44 && <= 46 => EnemyAnimation.dying,
_ => EnemyAnimation.dead, // Catch-all for final frames
},
EnemyType.officer || EnemyType.mutant => switch (offset) {
>= 8 && <= 39 => EnemyAnimation.walking,
>= 40 && <= 41 => EnemyAnimation.attacking, // Only 2 attack frames!
42 => EnemyAnimation.pain,
>= 43 && <= 45 => EnemyAnimation.dying,
_ => EnemyAnimation.dead,
},
EnemyType.dog => switch (offset) {
// Dogs are special: 3 walk frames (24 total) and NO pain frame!
>= 8 && <= 31 => EnemyAnimation.walking,
>= 32 && <= 34 => EnemyAnimation.attacking, // Leap and bite
>= 35 && <= 37 => EnemyAnimation.dying,
_ => EnemyAnimation.dead,
},
};
}
int getSpriteFromAnimation({
required EnemyAnimation animation,
required int elapsedMs,
required int lastActionTime,
double angleDiff = 0,
int? walkFrameOverride, // Optional for custom timing
}) {
// 1. Calculate Octant for directional sprites (Idle/Walk)
int octant = ((angleDiff + (math.pi / 8)) / (math.pi / 4)).floor() % 8;
if (octant < 0) octant += 8;
return switch (animation) {
EnemyAnimation.idle => spriteBaseIdx + octant,
EnemyAnimation.walking => () {
int frameCount = this == EnemyType.dog ? 3 : 4;
int frame = walkFrameOverride ?? (elapsedMs ~/ 150) % frameCount;
return (spriteBaseIdx + 8) + (frame * 8) + octant;
}(),
EnemyAnimation.attacking => () {
int time = elapsedMs - lastActionTime;
return switch (this) {
EnemyType.guard || EnemyType.ss || EnemyType.dog =>
spriteBaseIdx +
(time < 150
? 40
: time < 300
? 41
: 40),
EnemyType.officer ||
EnemyType.mutant => spriteBaseIdx + (time < 200 ? 40 : 41),
};
}(),
EnemyAnimation.pain => spriteBaseIdx + (this == EnemyType.dog ? 32 : 42),
EnemyAnimation.dying => () {
int frame = (elapsedMs - lastActionTime) ~/ 150;
int maxFrames = this == EnemyType.dog ? 2 : 3;
int offset = this == EnemyType.dog ? 35 : 43;
return spriteBaseIdx + offset + (frame.clamp(0, maxFrames));
}(),
EnemyAnimation.dead => spriteBaseIdx + (this == EnemyType.dog ? 37 : 45),
};
}
}
abstract class Enemy extends Entity {
Enemy({
required super.x,
required super.y,
required super.spriteIndex,
super.angle,
super.state,
super.mapId,
super.lastActionTime,
});
int health = 25;
int damage = 10;
bool isDying = false;
bool hasDroppedItem = false;
// Replaces ob->temp2 for reaction delays
int reactionTimeMs = 0;
void takeDamage(int amount, int currentTime) {
if (state == EntityState.dead) return;
health -= amount;
lastActionTime = currentTime;
if (health <= 0) {
state = EntityState.dead;
isDying = true;
} else if (math.Random().nextDouble() < 0.5) {
state = EntityState.pain;
} else {
state = EntityState.patrolling;
}
}
void checkWakeUp({
required int elapsedMs,
required Coordinate2D playerPosition,
required bool Function(int x, int y) isWalkable,
int baseReactionMs = 200,
int reactionVarianceMs = 600,
}) {
if (state == EntityState.idle &&
hasLineOfSight(playerPosition, isWalkable)) {
if (reactionTimeMs == 0) {
reactionTimeMs =
elapsedMs +
baseReactionMs +
math.Random().nextInt(reactionVarianceMs);
} else if (elapsedMs >= reactionTimeMs) {
state = EntityState.patrolling;
lastActionTime = elapsedMs;
reactionTimeMs = 0;
}
}
}
// Matches WL_STATE.C's 'CheckLine' using canonical Integer DDA traversal
bool hasLineOfSight(
Coordinate2D playerPosition,
bool Function(int x, int y) isWalkable,
) {
// 1. Proximity Check (Matches WL_STATE.C 'MINSIGHT')
// If the player is very close, sight is automatic regardless of facing angle.
// This compensates for our lack of a noise/gunshot alert system!
if (position.distanceTo(playerPosition) < 1.2) {
return true;
}
// 2. FOV Check (Matches original sight angles)
double angleToPlayer = position.angleTo(playerPosition);
double diff = angle - angleToPlayer;
while (diff <= -math.pi) {
diff += 2 * math.pi;
}
while (diff > math.pi) {
diff -= 2 * math.pi;
}
if (diff.abs() > math.pi / 2) return false;
// 3. Map Check (Corrected Integer Bresenham)
int currentX = position.x.toInt();
int currentY = position.y.toInt();
int targetX = playerPosition.x.toInt();
int targetY = playerPosition.y.toInt();
int dx = (targetX - currentX).abs();
int dy = -(targetY - currentY).abs();
int sx = currentX < targetX ? 1 : -1;
int sy = currentY < targetY ? 1 : -1;
int err = dx + dy;
while (true) {
if (!isWalkable(currentX, currentY)) return false;
if (currentX == targetX && currentY == targetY) break;
int e2 = 2 * err;
if (e2 >= dy) {
err += dy;
currentX += sx;
}
if (e2 <= dx) {
err += dx;
currentY += sy;
}
}
return true;
}
Coordinate2D getValidMovement(
Coordinate2D intendedMovement,
bool Function(int x, int y) isWalkable,
void Function(int x, int y) tryOpenDoor,
) {
double newX = position.x + intendedMovement.x;
double newY = position.y + intendedMovement.y;
int currentTileX = position.x.toInt();
int currentTileY = position.y.toInt();
int targetTileX = newX.toInt();
int targetTileY = newY.toInt();
bool movedX = currentTileX != targetTileX;
bool movedY = currentTileY != targetTileY;
// 1. Check Diagonal Movement
if (movedX && movedY) {
bool canMoveX = isWalkable(targetTileX, currentTileY);
bool canMoveY = isWalkable(currentTileX, targetTileY);
bool canMoveDiag = isWalkable(targetTileX, targetTileY);
if (!canMoveX || !canMoveY || !canMoveDiag) {
// Trigger doors if they are blocking the path
if (!canMoveX) tryOpenDoor(targetTileX, currentTileY);
if (!canMoveY) tryOpenDoor(currentTileX, targetTileY);
if (!canMoveDiag) tryOpenDoor(targetTileX, targetTileY);
if (canMoveX) return Coordinate2D(intendedMovement.x, 0);
if (canMoveY) return Coordinate2D(0, intendedMovement.y);
return const Coordinate2D(0, 0);
}
}
// 2. Check Cardinal Movement
if (movedX && !movedY) {
if (!isWalkable(targetTileX, currentTileY)) {
tryOpenDoor(targetTileX, currentTileY); // Try to open!
return Coordinate2D(0, intendedMovement.y);
}
}
if (movedY && !movedX) {
if (!isWalkable(currentTileX, targetTileY)) {
tryOpenDoor(currentTileX, targetTileY); // Try to open!
return Coordinate2D(intendedMovement.x, 0);
}
}
return intendedMovement;
}
// Updated Signature
({Coordinate2D movement, double newAngle}) update({
required int elapsedMs,
required Coordinate2D playerPosition,
required bool Function(int x, int y) isWalkable,
required void Function(int x, int y) tryOpenDoor,
required void Function(int damage) onDamagePlayer,
});
/// Centralized factory to handle all enemy spawning logic
static Enemy? spawn(
int objId,
double x,
double y,
Difficulty difficulty, {
bool isSharewareMode = false,
}) {
// 1. Check Difficulty & Compatibility
if (!MapObject.shouldSpawn(objId, difficulty)) return null;
// If the checkbox is checked, block non-Shareware enemies
if (isSharewareMode && !MapObject.isSharewareCompatible(objId)) return null;
final type = EnemyType.fromMapId(objId);
if (type == null) return null;
bool isPatrolling = objId >= type.mapBaseId + 18;
double spawnAngle = MapObject.getAngle(objId);
// 2. Return the specific instance
return switch (type) {
EnemyType.guard => Guard(x: x, y: y, angle: spawnAngle, mapId: objId),
EnemyType.dog => Dog(x: x, y: y, angle: spawnAngle, mapId: objId),
EnemyType.ss => SS(x: x, y: y, angle: spawnAngle, mapId: objId),
EnemyType.mutant => Mutant(x: x, y: y, angle: spawnAngle, mapId: objId),
EnemyType.officer => Officer(x: x, y: y, angle: spawnAngle, mapId: objId),
}..state = isPatrolling ? EntityState.patrolling : EntityState.idle;
}
}

View File

@@ -1,86 +0,0 @@
import 'dart:math' as math;
import 'package:wolf_dart/classes/coordinate_2d.dart';
import 'package:wolf_dart/features/entities/enemies/enemy.dart';
import 'package:wolf_dart/features/entities/entity.dart';
class Guard extends Enemy {
static const double speed = 0.03;
bool _hasFiredThisCycle = false;
static EnemyType get type => EnemyType.guard;
Guard({
required super.x,
required super.y,
required super.angle,
required super.mapId,
}) : super(
spriteIndex: type.spriteBaseIdx,
state: EntityState.idle,
);
@override
({Coordinate2D movement, double newAngle}) update({
required int elapsedMs,
required Coordinate2D playerPosition,
required bool Function(int x, int y) isWalkable,
required void Function(int damage) onDamagePlayer,
required void Function(int x, int y) tryOpenDoor,
}) {
Coordinate2D movement = const Coordinate2D(0, 0);
double newAngle = angle;
checkWakeUp(
elapsedMs: elapsedMs,
playerPosition: playerPosition,
isWalkable: isWalkable,
);
double distance = position.distanceTo(playerPosition);
double angleToPlayer = position.angleTo(playerPosition);
if (state != EntityState.idle && state != EntityState.dead) {
newAngle = angleToPlayer;
}
// Calculate angle diff for the octant logic
double diff = angleToPlayer - newAngle;
while (diff <= -math.pi) {
diff += 2 * math.pi;
}
while (diff > math.pi) {
diff -= 2 * math.pi;
}
// Helper to get sprite based on current state
EnemyAnimation currentAnim = switch (state) {
EntityState.patrolling => EnemyAnimation.walking,
EntityState.attacking => EnemyAnimation.attacking,
EntityState.pain => EnemyAnimation.pain,
EntityState.dead => isDying ? EnemyAnimation.dying : EnemyAnimation.dead,
_ => EnemyAnimation.idle,
};
spriteIndex = type.getSpriteFromAnimation(
animation: currentAnim,
elapsedMs: elapsedMs,
lastActionTime: lastActionTime,
angleDiff: diff,
);
// Logic triggers (Damage, State transitions)
if (state == EntityState.attacking) {
int time = elapsedMs - lastActionTime;
if (time >= 150 && time < 300 && !_hasFiredThisCycle) {
onDamagePlayer(10);
_hasFiredThisCycle = true;
} else if (time >= 450) {
state = EntityState.patrolling;
lastActionTime = elapsedMs;
}
}
return (movement: movement, newAngle: newAngle);
}
}

View File

@@ -1,87 +0,0 @@
import 'dart:math' as math;
import 'package:wolf_dart/classes/coordinate_2d.dart';
import 'package:wolf_dart/features/entities/enemies/enemy.dart';
import 'package:wolf_dart/features/entities/entity.dart';
class Mutant extends Enemy {
static const double speed = 0.04;
bool _hasFiredThisCycle = false;
static EnemyType get type => EnemyType.mutant;
Mutant({
required super.x,
required super.y,
required super.angle,
required super.mapId,
}) : super(
spriteIndex: type.spriteBaseIdx,
state: EntityState.idle,
) {
health = 45;
damage = 10;
}
@override
({Coordinate2D movement, double newAngle}) update({
required int elapsedMs,
required Coordinate2D playerPosition,
required bool Function(int x, int y) isWalkable,
required void Function(int damage) onDamagePlayer,
required void Function(int x, int y) tryOpenDoor,
}) {
Coordinate2D movement = const Coordinate2D(0, 0);
double newAngle = angle;
checkWakeUp(
elapsedMs: elapsedMs,
playerPosition: playerPosition,
isWalkable: isWalkable,
);
double distance = position.distanceTo(playerPosition);
double angleToPlayer = position.angleTo(playerPosition);
if (state != EntityState.idle && state != EntityState.dead) {
newAngle = angleToPlayer;
}
// Calculate angle diff for the octant logic
double diff = angleToPlayer - newAngle;
while (diff <= -math.pi) {
diff += 2 * math.pi;
}
while (diff > math.pi) {
diff -= 2 * math.pi;
}
EnemyAnimation currentAnim = switch (state) {
EntityState.patrolling => EnemyAnimation.walking,
EntityState.attacking => EnemyAnimation.attacking,
EntityState.pain => EnemyAnimation.pain,
EntityState.dead => isDying ? EnemyAnimation.dying : EnemyAnimation.dead,
_ => EnemyAnimation.idle,
};
spriteIndex = type.getSpriteFromAnimation(
animation: currentAnim,
elapsedMs: elapsedMs,
lastActionTime: lastActionTime,
angleDiff: diff,
);
if (state == EntityState.attacking) {
int time = elapsedMs - lastActionTime;
if (time >= 150 && !_hasFiredThisCycle) {
onDamagePlayer(damage);
_hasFiredThisCycle = true;
} else if (time >= 300) {
state = EntityState.patrolling;
lastActionTime = elapsedMs;
}
}
return (movement: movement, newAngle: newAngle);
}
}

View File

@@ -1,134 +0,0 @@
import 'dart:math' as math;
import 'package:wolf_dart/classes/coordinate_2d.dart';
import 'package:wolf_dart/features/entities/enemies/enemy.dart';
import 'package:wolf_dart/features/entities/entity.dart';
class Officer extends Enemy {
static const double speed = 0.055;
bool _hasFiredThisCycle = false;
Officer({
required super.x,
required super.y,
required super.angle,
required super.mapId,
}) : super(
spriteIndex: EnemyType.officer.spriteBaseIdx,
state: EntityState.idle,
) {
health = 50;
damage = 15;
}
@override
({Coordinate2D movement, double newAngle}) update({
required int elapsedMs,
required Coordinate2D playerPosition,
required bool Function(int x, int y) isWalkable,
required void Function(int damage) onDamagePlayer,
required void Function(int x, int y) tryOpenDoor,
}) {
Coordinate2D movement = const Coordinate2D(0, 0);
double newAngle = angle;
checkWakeUp(
elapsedMs: elapsedMs,
playerPosition: playerPosition,
isWalkable: isWalkable,
);
double distance = position.distanceTo(playerPosition);
double angleToPlayer = position.angleTo(playerPosition);
if (state != EntityState.idle && state != EntityState.dead) {
newAngle = angleToPlayer;
}
double diff = angleToPlayer - newAngle;
while (diff <= -math.pi) {
diff += 2 * math.pi;
}
while (diff > math.pi) {
diff -= 2 * math.pi;
}
int octant = ((diff + (math.pi / 8)) / (math.pi / 4)).floor() % 8;
if (octant < 0) octant += 8;
switch (state) {
case EntityState.idle:
spriteIndex = EnemyType.officer.spriteBaseIdx + octant;
break;
case EntityState.patrolling:
if (distance > 0.8) {
double moveX = math.cos(angleToPlayer) * speed;
double moveY = math.sin(angleToPlayer) * speed;
movement = getValidMovement(
Coordinate2D(moveX, moveY),
isWalkable,
tryOpenDoor,
);
}
int walkFrame = (elapsedMs ~/ 150) % 4;
spriteIndex =
(EnemyType.officer.spriteBaseIdx + 8) + (walkFrame * 8) + octant;
if (distance < 6.0 && elapsedMs - lastActionTime > 1000) {
if (hasLineOfSight(playerPosition, isWalkable)) {
state = EntityState.attacking;
lastActionTime = elapsedMs;
_hasFiredThisCycle = false;
}
}
break;
case EntityState.attacking:
int timeShooting = elapsedMs - lastActionTime;
if (timeShooting < 150) {
spriteIndex = EnemyType.officer.spriteBaseIdx + 40; // Aiming
} else if (timeShooting < 300) {
spriteIndex = EnemyType.officer.spriteBaseIdx + 41; // Firing
if (!_hasFiredThisCycle) {
onDamagePlayer(damage);
_hasFiredThisCycle = true;
}
} else if (timeShooting < 450) {
spriteIndex = EnemyType.officer.spriteBaseIdx + 40; // Recoil
} else {
state = EntityState.patrolling;
lastActionTime = elapsedMs;
}
break;
case EntityState.pain:
spriteIndex = EnemyType.officer.spriteBaseIdx + 42;
if (elapsedMs - lastActionTime > 250) {
state = EntityState.patrolling;
lastActionTime = elapsedMs;
}
break;
case EntityState.dead:
if (isDying) {
int deathFrame = (elapsedMs - lastActionTime) ~/ 150;
if (deathFrame < 3) {
spriteIndex = (EnemyType.officer.spriteBaseIdx + 43) + deathFrame;
} else {
spriteIndex = EnemyType.officer.spriteBaseIdx + 45;
isDying = false;
}
} else {
spriteIndex = EnemyType.officer.spriteBaseIdx + 45;
}
break;
default:
break;
}
return (movement: movement, newAngle: newAngle);
}
}

View File

@@ -1,135 +0,0 @@
import 'dart:math' as math;
import 'package:wolf_dart/classes/coordinate_2d.dart';
import 'package:wolf_dart/features/entities/enemies/enemy.dart';
import 'package:wolf_dart/features/entities/entity.dart';
class SS extends Enemy {
static const double speed = 0.04;
bool _hasFiredThisCycle = false;
SS({
required super.x,
required super.y,
required super.angle,
required super.mapId,
}) : super(
spriteIndex: EnemyType.ss.spriteBaseIdx,
state: EntityState.idle,
) {
health = 100;
damage = 20;
}
@override
({Coordinate2D movement, double newAngle}) update({
required int elapsedMs,
required Coordinate2D playerPosition,
required bool Function(int x, int y) isWalkable,
required void Function(int damage) onDamagePlayer,
required void Function(int x, int y) tryOpenDoor,
}) {
Coordinate2D movement = const Coordinate2D(0, 0);
double newAngle = angle;
checkWakeUp(
elapsedMs: elapsedMs,
playerPosition: playerPosition,
isWalkable: isWalkable,
);
double distance = position.distanceTo(playerPosition);
double angleToPlayer = position.angleTo(playerPosition);
if (state != EntityState.idle && state != EntityState.dead) {
newAngle = angleToPlayer;
}
double diff = angleToPlayer - newAngle;
while (diff <= -math.pi) {
diff += 2 * math.pi;
}
while (diff > math.pi) {
diff -= 2 * math.pi;
}
int octant = ((diff + (math.pi / 8)) / (math.pi / 4)).floor() % 8;
if (octant < 0) octant += 8;
switch (state) {
case EntityState.idle:
spriteIndex = EnemyType.ss.spriteBaseIdx + octant;
break;
case EntityState.patrolling:
if (distance > 0.8) {
double moveX = math.cos(angleToPlayer) * speed;
double moveY = math.sin(angleToPlayer) * speed;
movement = getValidMovement(
Coordinate2D(moveX, moveY),
isWalkable,
tryOpenDoor,
);
}
int walkFrame = (elapsedMs ~/ 150) % 4;
spriteIndex =
(EnemyType.ss.spriteBaseIdx + 8) + (walkFrame * 8) + octant;
if (distance < 6.0 && elapsedMs - lastActionTime > 1500) {
if (hasLineOfSight(playerPosition, isWalkable)) {
state = EntityState.attacking;
lastActionTime = elapsedMs;
_hasFiredThisCycle = false;
}
}
break;
case EntityState.attacking:
// SS machine gun fires much faster than a standard pistol!
int timeShooting = elapsedMs - lastActionTime;
if (timeShooting < 100) {
spriteIndex = EnemyType.ss.spriteBaseIdx + 46; // Aiming
} else if (timeShooting < 200) {
spriteIndex = EnemyType.ss.spriteBaseIdx + 47; // Firing
if (!_hasFiredThisCycle) {
onDamagePlayer(damage);
_hasFiredThisCycle = true;
}
} else if (timeShooting < 300) {
spriteIndex = EnemyType.ss.spriteBaseIdx + 48; // Recoil
} else {
state = EntityState.patrolling;
lastActionTime = elapsedMs;
}
break;
case EntityState.pain:
spriteIndex = EnemyType.ss.spriteBaseIdx + 44;
if (elapsedMs - lastActionTime > 250) {
state = EntityState.patrolling;
lastActionTime = elapsedMs;
}
break;
case EntityState.dead:
if (isDying) {
int deathFrame = (elapsedMs - lastActionTime) ~/ 150;
if (deathFrame < 4) {
spriteIndex = (EnemyType.ss.spriteBaseIdx + 40) + deathFrame;
} else {
spriteIndex = EnemyType.ss.spriteBaseIdx + 45;
isDying = false;
}
} else {
spriteIndex = EnemyType.ss.spriteBaseIdx + 45;
}
break;
default:
break;
}
return (movement: movement, newAngle: newAngle);
}
}

View File

@@ -1,66 +0,0 @@
import 'package:wolf_dart/classes/coordinate_2d.dart';
enum EntityState { staticObj, idle, patrolling, attacking, pain, dead }
abstract class Entity<T> {
double x;
double y;
int spriteIndex;
double angle;
EntityState state;
int mapId;
int lastActionTime;
Entity({
required this.x,
required this.y,
required this.spriteIndex,
this.angle = 0.0,
this.state = EntityState.staticObj,
this.mapId = 0,
this.lastActionTime = 0,
});
set position(Coordinate2D pos) {
x = pos.x;
y = pos.y;
}
Coordinate2D get position => Coordinate2D(x, y);
// NEW: Checks if a projectile or sightline from 'source' can reach this entity
bool hasLineOfSightFrom(
Coordinate2D source,
double sourceAngle,
double distance,
bool Function(int x, int y) isWalkable,
) {
// Corrected Integer Bresenham Algorithm
int currentX = source.x.toInt();
int currentY = source.y.toInt();
int targetX = x.toInt();
int targetY = y.toInt();
int dx = (targetX - currentX).abs();
int dy = -(targetY - currentY).abs();
int sx = currentX < targetX ? 1 : -1;
int sy = currentY < targetY ? 1 : -1;
int err = dx + dy;
while (true) {
if (!isWalkable(currentX, currentY)) return false;
if (currentX == targetX && currentY == targetY) break;
int e2 = 2 * err;
if (e2 >= dy) {
err += dy;
currentX += sx;
}
if (e2 <= dx) {
err += dx;
currentY += sy;
}
}
return true;
}
}

View File

@@ -1,68 +0,0 @@
import 'package:wolf_dart/features/difficulty/difficulty.dart';
import 'package:wolf_dart/features/entities/collectible.dart';
import 'package:wolf_dart/features/entities/decorative.dart';
import 'package:wolf_dart/features/entities/enemies/bosses/hans_grosse.dart';
import 'package:wolf_dart/features/entities/enemies/enemy.dart';
import 'package:wolf_dart/features/entities/entity.dart';
import 'package:wolf_dart/features/entities/map_objects.dart';
typedef EntitySpawner =
Entity? Function(
int objId,
double x,
double y,
Difficulty difficulty, {
bool isSharewareMode,
});
abstract class EntityRegistry {
static final List<EntitySpawner> _spawners = [
// Enemies need to try to spawn first
Enemy.spawn,
// Bosses
HansGrosse.trySpawn,
// Everything else
Collectible.trySpawn,
Decorative.trySpawn,
];
static Entity? spawn(
int objId,
double x,
double y,
Difficulty difficulty,
int maxSprites, {
bool isSharewareMode = false,
}) {
// 1. Difficulty check before even looking for a spawner
if (!MapObject.shouldSpawn(objId, difficulty)) return null;
// If the checkbox is checked, block non-Shareware enemies
if (isSharewareMode && !MapObject.isSharewareCompatible(objId)) return null;
if (objId == 0) return null;
for (final spawner in _spawners) {
Entity? entity = spawner(objId, x, y, difficulty);
final EnemyType? type = EnemyType.fromMapId(objId);
if (type != null) {
print("Spawning ${type.name} enemy");
}
if (entity != null) {
// Safety bounds check for the VSWAP array
if (entity.spriteIndex >= 0 && entity.spriteIndex < maxSprites) {
print("Spawned entity with objId $objId");
return entity;
}
print("VSWAP doesn't have this sprite! objId $objId");
return null; // VSWAP doesn't have this sprite!
}
}
print("No class claimed this Map ID > objId $objId");
return null; // No class claimed this Map ID
}
}

View File

@@ -1,162 +0,0 @@
import 'package:wolf_dart/classes/cardinal_direction.dart';
import 'package:wolf_dart/features/difficulty/difficulty.dart';
import 'package:wolf_dart/features/entities/enemies/enemy.dart';
abstract class MapObject {
// --- Player Spawns ---
static const int playerNorth = 19;
static const int playerEast = 20;
static const int playerSouth = 21;
static const int playerWest = 22;
// --- Static Decorations ---
static const int waterPuddle = 23;
static const int greenBarrel = 24;
static const int chairTable = 25;
static const int floorLamp = 26;
static const int chandelier = 27;
static const int hangingSkeleton = 28;
static const int dogFoodDecoration = 29;
static const int whiteColumn = 30;
static const int pottedPlant = 31;
static const int blueSkeleton = 32;
static const int vent = 33;
static const int kitchenCans = 34;
static const int exitSign = 35;
static const int brownPlant = 36;
static const int bowl = 37;
static const int armoredSuit = 38;
static const int emptyCage = 39;
static const int cageWithSkeleton = 40;
static const int bones = 41;
static const int goldenKeyBowl = 42;
// --- Collectibles ---
static const int goldKey = 43;
static const int silverKey = 44;
static const int bed = 45;
static const int basket = 46;
static const int food = 47;
static const int medkit = 48;
static const int ammoClip = 49;
static const int machineGun = 50;
static const int chainGun = 51;
static const int cross = 52;
static const int chalice = 53;
static const int chest = 54;
static const int crown = 55;
static const int extraLife = 56;
// --- Environmental ---
static const int bloodPoolSmall = 57;
static const int barrel = 58;
static const int wellFull = 59;
static const int wellEmpty = 60;
static const int bloodPoolLarge = 61;
static const int flag = 62;
static const int aardwolfSign = 63;
static const int bonesAndSkull = 64;
static const int wallHanging = 65;
static const int stove = 66;
static const int spearRack = 67;
static const int vines = 68;
// --- Logic & Triggers ---
static const int pushwallTrigger = 98;
static const int secretExitTrigger = 99;
static const int normalExitTrigger = 100;
// --- Wall Textures (From VSWAP/MAPHEAD) ---
static const int normalElevatorSwitch = 21;
static const int secretElevatorSwitch = 41;
// Bosses (Shared between WL1 and WL6)
static const int bossHansGrosse = 214;
// WL6 Exclusive Bosses
static const int bossDrSchabbs = 215;
static const int bossTransGrosse = 216;
static const int bossUbermutant = 217;
static const int bossDeathKnight = 218;
static const int bossMechaHitler = 219;
static const int bossHitlerGhost = 220;
static const int bossGretelGrosse = 221;
static const int bossGiftmacher = 222;
static const int bossFettgesicht = 223;
// --- Enemy Range Constants ---
static const int guardStart = 108; // 108-143
static const int officerStart = 144; // 144-179
static const int ssStart = 180; // 180-215
static const int dogStart = 216; // 216-251
static const int mutantStart = 252; // 252-287
// --- Missing Decorative Bodies ---
static const int deadGuard = 124; // Decorative only in WL1
static const int deadAardwolf = 125; // Decorative only in WL1
/// Returns true if the object ID exists in the Shareware version.
/// Returns true if the object ID exists in the Shareware version.
static bool isSharewareCompatible(int id) {
// Standard Decorations & Collectibles
if (id <= vines) return true;
// Logic Triggers (Exits/Pushwalls)
if (id >= pushwallTrigger && id <= normalExitTrigger) return true;
// Guards (108-143 includes dead bodies at 124/125)
if (id >= guardStart && id < officerStart) return true;
// Dogs (216-251) - These ARE in Shareware!
if (id >= dogStart && id < mutantStart) return true;
// Episode 1 Boss
if (id == bossHansGrosse) return true;
return false;
}
static double getAngle(int id) {
// Player spawn
switch (id) {
case playerNorth:
return CardinalDirection.north.radians;
case playerEast:
return CardinalDirection.east.radians;
case playerSouth:
return CardinalDirection.south.radians;
case playerWest:
return CardinalDirection.west.radians;
}
// Boss check
if (id == bossHansGrosse) return 0.0;
final EnemyType? type = EnemyType.fromMapId(id);
if (type == null) return 0.0; // Not a standard directional enemy
// Normalize patrolling enemies back to the standing block, THEN get the
// 4-way angle
int directionIndex = ((id - type.mapBaseId) % 18) % 4;
return CardinalDirection.fromEnemyIndex(directionIndex).radians;
}
static bool shouldSpawn(int id, Difficulty selectedDifficulty) {
EnemyType? type = EnemyType.fromMapId(id);
// If it's not a standard enemy (it's a decoration, boss, or player), spawn it
if (type == null) return true;
int offset = id - type.mapBaseId;
int normalizedOffset = offset >= 18 ? offset - 18 : offset;
return switch (normalizedOffset) {
< 4 => true, // Spawns on all difficulties
< 8 => selectedDifficulty.level >= Difficulty.bringEmOn.level, // Normal
< 16 =>
selectedDifficulty.level >=
Difficulty.iAmDeathIncarnate.level, // Hard & Ambush
_ => true, // Dead bodies (decorations)
};
}
}

View File

@@ -1,121 +0,0 @@
import 'dart:math' as math;
import 'package:wolf_3d_data_types/wolf_3d_data_types.dart';
import 'package:wolf_dart/features/entities/map_objects.dart';
class Pushwall {
int x;
int y;
int mapId;
int dirX = 0;
int dirY = 0;
double offset = 0.0;
int tilesMoved = 0;
Pushwall(this.x, this.y, this.mapId);
}
class PushwallManager {
final Map<String, Pushwall> pushwalls = {};
Pushwall? activePushwall;
void initPushwalls(Sprite wallGrid, Sprite objectGrid) {
pushwalls.clear();
activePushwall = null;
for (int y = 0; y < objectGrid.length; y++) {
for (int x = 0; x < objectGrid[y].length; x++) {
if (objectGrid[y][x] == MapObject.pushwallTrigger) {
pushwalls['$x,$y'] = Pushwall(x, y, wallGrid[y][x]);
}
}
}
}
void update(Duration elapsed, Sprite wallGrid) {
if (activePushwall == null) return;
final pw = activePushwall!;
// Original logic: 1/128 tile per tick.
// At 70 ticks/sec, that is roughly 0.54 tiles per second.
const double originalSpeed = 0.546875;
pw.offset += (elapsed.inMilliseconds / 1000.0) * originalSpeed;
// Once it crosses a full tile boundary, we update the collision grid!
if (pw.offset >= 1.0) {
pw.offset -= 1.0;
pw.tilesMoved++;
int nextX = pw.x + pw.dirX;
int nextY = pw.y + pw.dirY;
// Move the solid block in the physical grid
wallGrid[nextY][nextX] = pw.mapId;
wallGrid[pw.y][pw.x] = 0; // Clear the old space so the player can walk in
// Update the dictionary key
pushwalls.remove('${pw.x},${pw.y}');
pw.x = nextX;
pw.y = nextY;
pushwalls['${pw.x},${pw.y}'] = pw;
// Check if we should keep sliding
bool blocked = false;
int checkX = pw.x + pw.dirX;
int checkY = pw.y + pw.dirY;
if (checkX < 0 ||
checkX >= wallGrid[0].length ||
checkY < 0 ||
checkY >= wallGrid.length) {
blocked = true;
} else if (wallGrid[checkY][checkX] != 0) {
blocked = true; // Blocked by another wall or a door
}
// Standard Wolf3D pushwalls move exactly 2 tiles (or 1 if blocked)
if (pw.tilesMoved >= 2 || blocked) {
activePushwall = null;
pw.offset = 0.0;
}
}
}
void handleInteraction(
double playerX,
double playerY,
double playerAngle,
Sprite wallGrid,
) {
// Only one pushwall can move at a time in the original engine!
if (activePushwall != null) return;
int targetX = (playerX + math.cos(playerAngle)).toInt();
int targetY = (playerY + math.sin(playerAngle)).toInt();
String key = '$targetX,$targetY';
if (pushwalls.containsKey(key)) {
final pw = pushwalls[key]!;
// Determine the push direction based on the player's relative position
double dx = (targetX + 0.5) - playerX;
double dy = (targetY + 0.5) - playerY;
if (dx.abs() > dy.abs()) {
pw.dirX = dx > 0 ? 1 : -1;
pw.dirY = 0;
} else {
pw.dirX = 0;
pw.dirY = dy > 0 ? 1 : -1;
}
// Make sure the tile behind the wall is empty before starting the push
int checkX = targetX + pw.dirX;
int checkY = targetY + pw.dirY;
if (wallGrid[checkY][checkX] == 0) {
activePushwall = pw;
}
}
}
}

View File

@@ -1,62 +0,0 @@
import 'package:flutter/services.dart';
import 'package:wolf_dart/features/weapon/weapon.dart';
class InputManager {
Set<LogicalKeyboardKey> _previousKeys = {};
bool isMovingForward = false;
bool isMovingBackward = false;
bool isTurningLeft = false;
bool isTurningRight = false;
// Discrete (triggers once per press)
bool isInteracting = false;
// Continuous
bool isFiring = false;
WeaponType? requestedWeapon;
void update() {
final pressedKeys = HardwareKeyboard.instance.logicalKeysPressed;
// Calculate all keys that were pressed exactly on this frame
final newlyPressedKeys = pressedKeys.difference(_previousKeys);
// * Movement
isMovingForward = pressedKeys.contains(LogicalKeyboardKey.keyW);
isMovingBackward = pressedKeys.contains(LogicalKeyboardKey.keyS);
isTurningLeft = pressedKeys.contains(LogicalKeyboardKey.keyA);
isTurningRight = pressedKeys.contains(LogicalKeyboardKey.keyD);
// * Interaction (Space)
// Much simpler now using the newlyPressedKeys set
isInteracting = newlyPressedKeys.contains(LogicalKeyboardKey.space);
// * Firing (Left Control)
// - Keeping this continuous for machine guns
isFiring =
pressedKeys.contains(LogicalKeyboardKey.controlLeft) &&
!pressedKeys.contains(LogicalKeyboardKey.space);
// * Manual Weapon Switching
requestedWeapon = null;
// Iterate through newly pressed keys and switch on them
for (final LogicalKeyboardKey key in newlyPressedKeys) {
switch (key) {
case LogicalKeyboardKey.digit1:
requestedWeapon = WeaponType.knife;
case LogicalKeyboardKey.digit2:
requestedWeapon = WeaponType.pistol;
case LogicalKeyboardKey.digit3:
requestedWeapon = WeaponType.machineGun;
case LogicalKeyboardKey.digit4:
requestedWeapon = WeaponType.chainGun;
}
}
// * Save state for next tick
_previousKeys = Set.from(pressedKeys);
}
}

View File

@@ -1,261 +0,0 @@
import 'dart:math' as math;
import 'package:wolf_dart/classes/coordinate_2d.dart';
import 'package:wolf_dart/features/entities/collectible.dart';
import 'package:wolf_dart/features/entities/enemies/enemy.dart';
import 'package:wolf_dart/features/entities/entity.dart';
import 'package:wolf_dart/features/entities/map_objects.dart';
import 'package:wolf_dart/features/weapon/weapon.dart';
import 'package:wolf_dart/features/weapon/weapons/chain_gun.dart';
import 'package:wolf_dart/features/weapon/weapons/knife.dart';
import 'package:wolf_dart/features/weapon/weapons/machine_gun.dart';
import 'package:wolf_dart/features/weapon/weapons/pistol.dart';
enum WeaponSwitchState { idle, lowering, raising }
class Player {
// Spatial
double x;
double y;
double angle;
// Stats
int health = 100;
int ammo = 8;
int score = 0;
// Inventory
bool hasGoldKey = false;
bool hasSilverKey = false;
bool hasMachineGun = false;
bool hasChainGun = false;
// Weapon System
late Weapon currentWeapon;
final Map<WeaponType, Weapon?> weapons = {
WeaponType.knife: Knife(),
WeaponType.pistol: Pistol(),
WeaponType.machineGun: null,
WeaponType.chainGun: null,
};
WeaponSwitchState switchState = WeaponSwitchState.idle;
WeaponType? pendingWeaponType;
// 0.0 is resting, 500.0 is fully off-screen
double weaponAnimOffset = 0.0;
// How fast the weapon drops/raises per tick
final double switchSpeed = 30.0;
Player({
required this.x,
required this.y,
required this.angle,
}) {
currentWeapon = weapons[WeaponType.pistol]!;
}
// Helper getter to interface with the RaycasterPainter
Coordinate2D get position => Coordinate2D(x, y);
// --- Weapon Switching & Animation Logic ---
void updateWeaponSwitch() {
if (switchState == WeaponSwitchState.lowering) {
// If the map doesn't contain the pending weapon, stop immediately
if (weapons[pendingWeaponType] == null) {
switchState = WeaponSwitchState.idle;
return;
}
weaponAnimOffset += switchSpeed;
if (weaponAnimOffset >= 500.0) {
weaponAnimOffset = 500.0;
// We already know it's not null now, but we can keep the
// fallback to pistol just to be extra safe.
currentWeapon = weapons[pendingWeaponType]!;
switchState = WeaponSwitchState.raising;
}
} else if (switchState == WeaponSwitchState.raising) {
weaponAnimOffset -= switchSpeed;
if (weaponAnimOffset <= 0) {
weaponAnimOffset = 0.0;
switchState = WeaponSwitchState.idle;
}
}
}
void requestWeaponSwitch(WeaponType weaponType) {
if (switchState != WeaponSwitchState.idle) return;
if (currentWeapon.state != WeaponState.idle) return;
if (weaponType == currentWeapon.type) return;
if (!weapons.containsKey(weaponType)) return;
if (weaponType != WeaponType.knife && ammo <= 0) return;
pendingWeaponType = weaponType;
switchState = WeaponSwitchState.lowering;
}
// --- Health & Damage ---
void takeDamage(int damage) {
health = math.max(0, health - damage);
if (health <= 0) {
print("YOU DIED!");
} else {
print("Ouch! ($health)");
}
}
void heal(int amount) {
final int newHealth = math.min(100, health + amount);
if (health < 100) {
print("Feelin' better. ($newHealth)");
}
health = newHealth;
}
void addAmmo(int amount) {
final int newAmmo = math.min(99, ammo + amount);
if (ammo < 99) {
print("Hell yeah. ($newAmmo)");
}
ammo = newAmmo;
}
bool tryPickup(Collectible item) {
bool pickedUp = false;
switch (item.type) {
case CollectibleType.health:
if (health >= 100) return false;
heal(item.mapId == MapObject.dogFoodDecoration ? 4 : 25);
pickedUp = true;
break;
case CollectibleType.ammo:
if (ammo >= 99) return false;
int previousAmmo = ammo;
addAmmo(8);
if (currentWeapon is Knife && previousAmmo <= 0) {
requestWeaponSwitch(WeaponType.pistol);
}
pickedUp = true;
break;
case CollectibleType.treasure:
if (item.mapId == MapObject.cross) score += 100;
if (item.mapId == MapObject.chalice) score += 500;
if (item.mapId == MapObject.chest) score += 1000;
if (item.mapId == MapObject.crown) score += 5000;
if (item.mapId == MapObject.extraLife) {
heal(100);
addAmmo(25);
}
pickedUp = true;
break;
case CollectibleType.weapon:
if (item.mapId == MapObject.machineGun) {
if (weapons[WeaponType.machineGun] == null) {
weapons[WeaponType.machineGun] = MachineGun();
hasMachineGun = true;
}
addAmmo(8);
requestWeaponSwitch(WeaponType.machineGun);
pickedUp = true;
}
if (item.mapId == MapObject.chainGun) {
if (weapons[WeaponType.chainGun] == null) {
weapons[WeaponType.chainGun] = ChainGun();
hasChainGun = true;
}
addAmmo(8);
requestWeaponSwitch(WeaponType.chainGun);
pickedUp = true;
}
break;
case CollectibleType.key:
if (item.mapId == MapObject.goldKey) hasGoldKey = true;
if (item.mapId == MapObject.silverKey) hasSilverKey = true;
pickedUp = true;
break;
}
return pickedUp;
}
void fire(int currentTime) {
if (switchState != WeaponSwitchState.idle) return;
// We pass the isFiring state to handle automatic vs semi-auto behavior
bool shotFired = currentWeapon.fire(
currentTime,
currentAmmo: ammo,
);
if (shotFired && currentWeapon.type != WeaponType.knife) {
ammo--;
}
}
void releaseTrigger() {
currentWeapon.releaseTrigger();
}
/// Returns true only on the specific frame where the hit should be calculated
void updateWeapon({
required int currentTime,
required List<Entity> entities,
required bool Function(int x, int y) isWalkable,
}) {
int oldFrame = currentWeapon.frameIndex;
currentWeapon.update(currentTime);
// If we just crossed into the firing frame...
if (currentWeapon.state == WeaponState.firing &&
oldFrame == 0 &&
currentWeapon.frameIndex == 1) {
currentWeapon.performHitscan(
playerX: x,
playerY: y,
playerAngle: angle,
entities: entities,
isWalkable: isWalkable,
currentTime: currentTime,
onEnemyKilled: (Enemy killedEnemy) {
// Dynamic scoring based on the enemy type!
int pointsToAdd = 0;
switch (killedEnemy.runtimeType.toString()) {
case 'BrownGuard':
pointsToAdd = 100;
break;
case 'Dog':
pointsToAdd = 200;
break;
// You can easily plug in future enemies here!
// case 'SSOfficer': pointsToAdd = 500; break;
default:
pointsToAdd = 100; // Fallback
}
score += pointsToAdd;
// Optional: Print to console so you can see it working
print(
"Killed ${killedEnemy.runtimeType}! +$pointsToAdd (Score: $score)",
);
},
);
}
if (currentWeapon.state == WeaponState.idle &&
ammo <= 0 &&
currentWeapon.type != WeaponType.knife) {
requestWeaponSwitch(WeaponType.knife);
}
}
}

View File

@@ -2,10 +2,8 @@ import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:wolf_3d_data_types/wolf_3d_data_types.dart';
import 'package:wolf_dart/classes/coordinate_2d.dart';
import 'package:wolf_dart/features/entities/entity.dart';
import 'package:wolf_dart/features/entities/pushwall_manager.dart';
import 'package:wolf_dart/features/player/player.dart';
import 'package:wolf_3d_engine/wolf_3d_engine.dart';
import 'package:wolf_3d_entities/wolf_3d_entities.dart';
import 'package:wolf_dart/features/renderer/color_palette.dart';
class RaycasterPainter extends CustomPainter {

View File

@@ -3,17 +3,8 @@ 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_dart/classes/coordinate_2d.dart';
import 'package:wolf_dart/features/difficulty/difficulty.dart';
import 'package:wolf_dart/features/entities/collectible.dart';
import 'package:wolf_dart/features/entities/door_manager.dart';
import 'package:wolf_dart/features/entities/enemies/enemy.dart';
import 'package:wolf_dart/features/entities/entity.dart';
import 'package:wolf_dart/features/entities/entity_registry.dart';
import 'package:wolf_dart/features/entities/map_objects.dart';
import 'package:wolf_dart/features/entities/pushwall_manager.dart';
import 'package:wolf_dart/features/input/input_manager.dart';
import 'package:wolf_dart/features/player/player.dart';
import 'package:wolf_3d_engine/wolf_3d_engine.dart';
import 'package:wolf_3d_entities/wolf_3d_entities.dart';
import 'package:wolf_dart/features/renderer/raycast_painter.dart';
import 'package:wolf_dart/features/renderer/weapon_painter.dart';
import 'package:wolf_dart/features/ui/hud.dart';

View File

@@ -1,6 +1,5 @@
import 'package:flutter/material.dart';
import 'package:wolf_3d_data_types/wolf_3d_data_types.dart';
import 'package:wolf_dart/features/difficulty/difficulty.dart';
import 'package:wolf_dart/features/renderer/renderer.dart';
import 'package:wolf_dart/wolf_3d.dart';

View File

@@ -1,5 +1,5 @@
import 'package:flutter/material.dart';
import 'package:wolf_dart/features/player/player.dart';
import 'package:wolf_3d_engine/wolf_3d_engine.dart';
class Hud extends StatelessWidget {
final Player player;

View File

@@ -1,138 +0,0 @@
import 'dart:math' as math;
import 'package:wolf_dart/classes/coordinate_2d.dart';
import 'package:wolf_dart/features/entities/enemies/enemy.dart';
import 'package:wolf_dart/features/entities/entity.dart';
enum WeaponState { idle, firing }
enum WeaponType { knife, pistol, machineGun, chainGun }
abstract class Weapon {
final WeaponType type;
final int idleSprite;
final List<int> fireFrames;
final int damage;
final int msPerFrame;
final bool isAutomatic;
WeaponState state = WeaponState.idle;
int frameIndex = 0;
int lastFrameTime = 0;
bool _triggerReleased = true;
Weapon({
required this.type,
required this.idleSprite,
required this.fireFrames,
required this.damage,
this.msPerFrame = 100,
this.isAutomatic = true,
});
int getCurrentSpriteIndex(int maxSprites) {
int baseSprite = state == WeaponState.idle
? idleSprite
: fireFrames[frameIndex];
// Retail VSWAP typically has exactly 436 sprites (indices 0 to 435).
// The 20 weapon sprites are ALWAYS placed at the very end of the sprite block.
// This dynamically aligns the base index to the end of any VSWAP file!
int dynamicOffset = 436 - maxSprites;
int calculatedIndex = baseSprite - dynamicOffset;
// Safety check!
if (calculatedIndex < 0 || calculatedIndex >= maxSprites) {
print("WARNING: Weapon sprite index $calculatedIndex out of bounds!");
return 0;
}
return calculatedIndex;
}
void releaseTrigger() {
_triggerReleased = true;
}
bool fire(int currentTime, {required int currentAmmo}) {
if (state == WeaponState.idle && currentAmmo > 0) {
if (!isAutomatic && !_triggerReleased) return false;
state = WeaponState.firing;
frameIndex = 0;
lastFrameTime = currentTime;
_triggerReleased = false;
return true;
}
return false;
}
void update(int currentTime) {
if (state == WeaponState.firing) {
if (currentTime - lastFrameTime > msPerFrame) {
frameIndex++;
lastFrameTime = currentTime;
if (frameIndex >= fireFrames.length) {
state = WeaponState.idle;
frameIndex = 0;
}
}
}
}
// NEW: The weapon calculates its own hits and applies damage!
void performHitscan({
required double playerX,
required double playerY,
required double playerAngle,
required List<Entity> entities,
required bool Function(int x, int y) isWalkable,
required int currentTime,
required void Function(Enemy killedEnemy) onEnemyKilled,
}) {
Enemy? closestEnemy;
double minDistance = 15.0;
for (Entity entity in entities) {
if (entity is Enemy && entity.state != EntityState.dead) {
double dx = entity.x - playerX;
double dy = entity.y - playerY;
double angleToEnemy = math.atan2(dy, dx);
double angleDiff = playerAngle - angleToEnemy;
while (angleDiff <= -math.pi) {
angleDiff += 2 * math.pi;
}
while (angleDiff > math.pi) {
angleDiff -= 2 * math.pi;
}
double dist = math.sqrt(dx * dx + dy * dy);
double threshold = 0.2 / dist;
if (angleDiff.abs() < threshold) {
Coordinate2D source = Coordinate2D(playerX, playerY);
if (entity.hasLineOfSightFrom(
source,
playerAngle,
dist,
isWalkable,
)) {
if (dist < minDistance) {
minDistance = dist;
closestEnemy = entity;
}
}
}
}
}
if (closestEnemy != null) {
closestEnemy.takeDamage(damage, currentTime);
// If the shot was fatal, pass the enemy back so the Player class
// can calculate the correct score based on enemy type!
if (closestEnemy.state == EntityState.dead) {
onEnemyKilled(closestEnemy);
}
}
}
}

View File

@@ -1,12 +0,0 @@
import 'package:wolf_dart/features/weapon/weapon.dart';
class ChainGun extends Weapon {
ChainGun()
: super(
type: WeaponType.chainGun,
idleSprite: 432,
fireFrames: [433, 434],
damage: 40,
msPerFrame: 30,
);
}

View File

@@ -1,24 +0,0 @@
import 'package:wolf_dart/features/weapon/weapon.dart';
class Knife extends Weapon {
Knife()
: super(
type: WeaponType.knife,
idleSprite: 416,
fireFrames: [417, 418, 419, 420],
damage: 15,
msPerFrame: 120,
isAutomatic: false,
);
@override
bool fire(int currentTime, {required int currentAmmo}) {
if (state == WeaponState.idle) {
state = WeaponState.firing;
frameIndex = 0;
lastFrameTime = currentTime;
return true;
}
return false;
}
}

View File

@@ -1,13 +0,0 @@
import 'package:wolf_dart/features/weapon/weapon.dart';
class MachineGun extends Weapon {
MachineGun()
: super(
type: WeaponType.machineGun,
idleSprite: 427,
fireFrames: [428, 429, 430],
damage: 20,
msPerFrame: 80, // MG fires faster than the Pistol
isAutomatic: true, // This allows holding the button!
);
}

View File

@@ -1,12 +0,0 @@
import 'package:wolf_dart/features/weapon/weapon.dart';
class Pistol extends Weapon {
Pistol()
: super(
type: WeaponType.pistol,
idleSprite: 421,
fireFrames: [422, 423, 424, 425],
damage: 20,
isAutomatic: false,
);
}

View File

@@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
import 'package:wolf_3d_data_types/wolf_3d_data_types.dart';
import 'package:wolf_dart/features/entities/enemies/enemy.dart';
import 'package:wolf_3d_entities/wolf_3d_entities.dart';
import 'package:wolf_dart/features/renderer/color_palette.dart';
class SpriteGallery extends StatelessWidget {