WIP: Refactoring game engine and entities into packages
Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)';
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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!
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user