WIP: Refactoring game engine and entities into packages
Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
7
packages/wolf_3d_entities/.gitignore
vendored
Normal file
7
packages/wolf_3d_entities/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
# https://dart.dev/guides/libraries/private-files
|
||||
# Created by `dart pub`
|
||||
.dart_tool/
|
||||
|
||||
# Avoid committing pubspec.lock for library packages; see
|
||||
# https://dart.dev/guides/libraries/private-files#pubspeclock.
|
||||
pubspec.lock
|
||||
3
packages/wolf_3d_entities/CHANGELOG.md
Normal file
3
packages/wolf_3d_entities/CHANGELOG.md
Normal file
@@ -0,0 +1,3 @@
|
||||
## 1.0.0
|
||||
|
||||
- Initial version.
|
||||
39
packages/wolf_3d_entities/README.md
Normal file
39
packages/wolf_3d_entities/README.md
Normal file
@@ -0,0 +1,39 @@
|
||||
<!--
|
||||
This README describes the package. If you publish this package to pub.dev,
|
||||
this README's contents appear on the landing page for your package.
|
||||
|
||||
For information about how to write a good package README, see the guide for
|
||||
[writing package pages](https://dart.dev/tools/pub/writing-package-pages).
|
||||
|
||||
For general information about developing packages, see the Dart guide for
|
||||
[creating packages](https://dart.dev/guides/libraries/create-packages)
|
||||
and the Flutter guide for
|
||||
[developing packages and plugins](https://flutter.dev/to/develop-packages).
|
||||
-->
|
||||
|
||||
TODO: Put a short description of the package here that helps potential users
|
||||
know whether this package might be useful for them.
|
||||
|
||||
## Features
|
||||
|
||||
TODO: List what your package can do. Maybe include images, gifs, or videos.
|
||||
|
||||
## Getting started
|
||||
|
||||
TODO: List prerequisites and provide or point to information on how to
|
||||
start using the package.
|
||||
|
||||
## Usage
|
||||
|
||||
TODO: Include short and useful examples for package users. Add longer examples
|
||||
to `/example` folder.
|
||||
|
||||
```dart
|
||||
const like = 'sample';
|
||||
```
|
||||
|
||||
## Additional information
|
||||
|
||||
TODO: Tell users more about the package: where to find more information, how to
|
||||
contribute to the package, how to file issues, what response they can expect
|
||||
from the package authors, and more.
|
||||
30
packages/wolf_3d_entities/analysis_options.yaml
Normal file
30
packages/wolf_3d_entities/analysis_options.yaml
Normal file
@@ -0,0 +1,30 @@
|
||||
# This file configures the static analysis results for your project (errors,
|
||||
# warnings, and lints).
|
||||
#
|
||||
# This enables the 'recommended' set of lints from `package:lints`.
|
||||
# This set helps identify many issues that may lead to problems when running
|
||||
# or consuming Dart code, and enforces writing Dart using a single, idiomatic
|
||||
# style and format.
|
||||
#
|
||||
# If you want a smaller set of lints you can change this to specify
|
||||
# 'package:lints/core.yaml'. These are just the most critical lints
|
||||
# (the recommended set includes the core lints).
|
||||
# The core lints are also what is used by pub.dev for scoring packages.
|
||||
|
||||
include: package:lints/recommended.yaml
|
||||
|
||||
# Uncomment the following section to specify additional rules.
|
||||
|
||||
# linter:
|
||||
# rules:
|
||||
# - camel_case_types
|
||||
|
||||
# analyzer:
|
||||
# exclude:
|
||||
# - path/to/excluded/files/**
|
||||
|
||||
# For more information about the core and recommended set of lints, see
|
||||
# https://dart.dev/go/core-lints
|
||||
|
||||
# For additional information about configuring this file, see
|
||||
# https://dart.dev/guides/language/analysis-options
|
||||
49
packages/wolf_3d_entities/lib/src/entities/collectible.dart
Normal file
49
packages/wolf_3d_entities/lib/src/entities/collectible.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
58
packages/wolf_3d_entities/lib/src/entities/decorative.dart
Normal file
58
packages/wolf_3d_entities/lib/src/entities/decorative.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
56
packages/wolf_3d_entities/lib/src/entities/door.dart
Normal file
56
packages/wolf_3d_entities/lib/src/entities/door.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
88
packages/wolf_3d_entities/lib/src/entities/enemies/dog.dart
Normal file
88
packages/wolf_3d_entities/lib/src/entities/enemies/dog.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
339
packages/wolf_3d_entities/lib/src/entities/enemies/enemy.dart
Normal file
339
packages/wolf_3d_entities/lib/src/entities/enemies/enemy.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
134
packages/wolf_3d_entities/lib/src/entities/enemies/officer.dart
Normal file
134
packages/wolf_3d_entities/lib/src/entities/enemies/officer.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
132
packages/wolf_3d_entities/lib/src/entities/enemies/ss.dart
Normal file
132
packages/wolf_3d_entities/lib/src/entities/enemies/ss.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
138
packages/wolf_3d_entities/lib/src/entities/weapon/weapon.dart
Normal file
138
packages/wolf_3d_entities/lib/src/entities/weapon/weapon.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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!
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
66
packages/wolf_3d_entities/lib/src/entity.dart
Normal file
66
packages/wolf_3d_entities/lib/src/entity.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
67
packages/wolf_3d_entities/lib/src/entity_registry.dart
Normal file
67
packages/wolf_3d_entities/lib/src/entity_registry.dart
Normal 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
|
||||
}
|
||||
}
|
||||
22
packages/wolf_3d_entities/lib/wolf_3d_entities.dart
Normal file
22
packages/wolf_3d_entities/lib/wolf_3d_entities.dart
Normal file
@@ -0,0 +1,22 @@
|
||||
/// Support for doing something awesome.
|
||||
///
|
||||
/// More dartdocs go here.
|
||||
library;
|
||||
|
||||
export 'src/entities/collectible.dart';
|
||||
export 'src/entities/decorative.dart';
|
||||
export 'src/entities/door.dart';
|
||||
export 'src/entities/enemies/bosses/hans_grosse.dart';
|
||||
export 'src/entities/enemies/dog.dart';
|
||||
export 'src/entities/enemies/enemy.dart';
|
||||
export 'src/entities/enemies/guard.dart';
|
||||
export 'src/entities/enemies/mutant.dart';
|
||||
export 'src/entities/enemies/officer.dart';
|
||||
export 'src/entities/enemies/ss.dart';
|
||||
export 'src/entities/weapon/weapon.dart';
|
||||
export 'src/entities/weapon/weapons/chain_gun.dart';
|
||||
export 'src/entities/weapon/weapons/knife.dart';
|
||||
export 'src/entities/weapon/weapons/machine_gun.dart';
|
||||
export 'src/entities/weapon/weapons/pistol.dart';
|
||||
export 'src/entity.dart';
|
||||
export 'src/entity_registry.dart';
|
||||
16
packages/wolf_3d_entities/pubspec.yaml
Normal file
16
packages/wolf_3d_entities/pubspec.yaml
Normal file
@@ -0,0 +1,16 @@
|
||||
name: wolf_3d_entities
|
||||
description: A starting point for Dart libraries or applications.
|
||||
version: 1.0.0
|
||||
# repository: https://github.com/my_org/my_repo
|
||||
|
||||
environment:
|
||||
sdk: ^3.11.1
|
||||
|
||||
resolution: workspace
|
||||
|
||||
dependencies:
|
||||
wolf_3d_data_types: any
|
||||
|
||||
dev_dependencies:
|
||||
lints: ^6.0.0
|
||||
test: ^1.25.6
|
||||
Reference in New Issue
Block a user