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

@@ -0,0 +1,49 @@
import 'package:wolf_3d_data_types/wolf_3d_data_types.dart';
import 'package:wolf_3d_entities/src/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

@@ -0,0 +1,58 @@
import 'package:wolf_3d_data_types/wolf_3d_data_types.dart';
import 'package:wolf_3d_entities/src/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

@@ -0,0 +1,56 @@
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

@@ -0,0 +1,150 @@
import 'dart:math' as math;
import 'package:wolf_3d_data_types/wolf_3d_data_types.dart';
import 'package:wolf_3d_entities/src/entities/enemies/enemy.dart';
import 'package:wolf_3d_entities/src/entity.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

@@ -0,0 +1,88 @@
import 'dart:math' as math;
import 'package:wolf_3d_data_types/wolf_3d_data_types.dart';
import 'package:wolf_3d_entities/src/entities/enemies/enemy.dart';
import 'package:wolf_3d_entities/src/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

@@ -0,0 +1,339 @@
import 'dart:math' as math;
import 'package:wolf_3d_data_types/wolf_3d_data_types.dart';
import 'package:wolf_3d_entities/src/entities/enemies/dog.dart';
import 'package:wolf_3d_entities/src/entities/enemies/guard.dart';
import 'package:wolf_3d_entities/src/entities/enemies/mutant.dart';
import 'package:wolf_3d_entities/src/entities/enemies/officer.dart';
import 'package:wolf_3d_entities/src/entities/enemies/ss.dart';
import 'package:wolf_3d_entities/src/entity.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

@@ -0,0 +1,83 @@
import 'dart:math' as math;
import 'package:wolf_3d_data_types/wolf_3d_data_types.dart';
import 'package:wolf_3d_entities/src/entities/enemies/enemy.dart';
import 'package:wolf_3d_entities/src/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

@@ -0,0 +1,84 @@
import 'dart:math' as math;
import 'package:wolf_3d_data_types/wolf_3d_data_types.dart';
import 'package:wolf_3d_entities/src/entities/enemies/enemy.dart';
import 'package:wolf_3d_entities/src/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

@@ -0,0 +1,134 @@
import 'dart:math' as math;
import 'package:wolf_3d_data_types/wolf_3d_data_types.dart';
import 'package:wolf_3d_entities/src/entities/enemies/enemy.dart';
import 'package:wolf_3d_entities/src/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

@@ -0,0 +1,132 @@
import 'dart:math' as math;
import 'package:wolf_3d_data_types/wolf_3d_data_types.dart';
import 'package:wolf_3d_entities/src/entities/enemies/enemy.dart';
import 'package:wolf_3d_entities/src/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

@@ -0,0 +1,138 @@
import 'dart:math' as math;
import 'package:wolf_3d_data_types/wolf_3d_data_types.dart';
import 'package:wolf_3d_entities/src/entities/enemies/enemy.dart';
import 'package:wolf_3d_entities/src/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

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

View File

@@ -0,0 +1,24 @@
import 'package:wolf_3d_entities/src/entities/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

@@ -0,0 +1,13 @@
import 'package:wolf_3d_entities/src/entities/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

@@ -0,0 +1,12 @@
import 'package:wolf_3d_entities/src/entities/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

@@ -0,0 +1,66 @@
import 'package:wolf_3d_data_types/wolf_3d_data_types.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

@@ -0,0 +1,67 @@
import 'package:wolf_3d_data_types/wolf_3d_data_types.dart';
import 'package:wolf_3d_entities/src/entities/collectible.dart';
import 'package:wolf_3d_entities/src/entities/decorative.dart';
import 'package:wolf_3d_entities/src/entities/enemies/bosses/hans_grosse.dart';
import 'package:wolf_3d_entities/src/entities/enemies/enemy.dart';
import 'package:wolf_3d_entities/src/entity.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
}
}