diff --git a/gleam.toml b/gleam.toml index 45fed9d..795282e 100644 --- a/gleam.toml +++ b/gleam.toml @@ -18,6 +18,7 @@ gleam_stdlib = ">= 0.36.0 and < 2.0.0" p5js_gleam = ">= 2.1.2 and < 3.0.0" prng = ">= 3.0.2 and < 4.0.0" gleam_community_maths = ">= 1.1.0 and < 2.0.0" +priorityq = ">= 0.1.0 and < 1.0.0" [dev-dependencies] esgleam = ">= 0.6.0 and < 1.0.0" diff --git a/manifest.toml b/manifest.toml index ac52b0d..35a692b 100644 --- a/manifest.toml +++ b/manifest.toml @@ -19,6 +19,7 @@ packages = [ { name = "gleam_stdlib", version = "0.38.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "663CF11861179AF415A625307447775C09404E752FF99A24E2057C835319F1BE" }, { name = "glint", version = "1.0.0-rc2", build_tools = ["gleam"], requirements = ["gleam_community_ansi", "gleam_community_colour", "gleam_stdlib", "snag"], otp_app = "glint", source = "hex", outer_checksum = "FD5C47CE237CA67121F3946ADE7C630750BB67F5E8A4717D2DF5B5EE758CCFDB" }, { name = "p5js_gleam", version = "2.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "p5js_gleam", source = "hex", outer_checksum = "6280E50C5CF893BA84B6A2D82A3EA83BB764EA7F3509CA22BB12326D01283F85" }, + { name = "priorityq", version = "0.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "priorityq", source = "hex", outer_checksum = "979D1654394E48CCB1F2758F4783ED1E36274F007CCC6A8C1BB5D2492889102D" }, { name = "prng", version = "3.0.3", build_tools = ["gleam"], requirements = ["gleam_bitwise", "gleam_stdlib"], otp_app = "prng", source = "hex", outer_checksum = "53006736FE23A0F61828C95B505193E10905D8DB76E128F1642D3E571E08F589" }, { name = "ranger", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "ranger", source = "hex", outer_checksum = "1566C272B1D141B3BBA38B25CB761EF56E312E79EC0E2DFD4D3C19FB0CC1F98C" }, { name = "simplifile", version = "1.7.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "1D5DFA3A2F9319EC85825F6ED88B8E449F381B0D55A62F5E61424E748E7DDEB0" }, @@ -33,5 +34,6 @@ esgleam = { version = ">= 0.6.0 and < 1.0.0" } gleam_community_maths = { version = ">= 1.1.0 and < 2.0.0" } gleam_stdlib = { version = ">= 0.36.0 and < 2.0.0" } p5js_gleam = { version = ">= 2.1.2 and < 3.0.0" } +priorityq = { version = ">= 0.1.0 and < 1.0.0"} prng = { version = ">= 3.0.2 and < 4.0.0" } startest = { version = ">= 0.2.1 and < 1.0.0" } diff --git a/src/dungeon.gleam b/src/dungeon.gleam index d141633..d08ca11 100644 --- a/src/dungeon.gleam +++ b/src/dungeon.gleam @@ -4,10 +4,12 @@ import gleam/float import gleam/int import gleam/iterator import gleam/list +import gleam/option import gleam/result import obstacle.{type Obstacle} import p5js_gleam.{type P5} import pit.{type Pit} +import priorityq import prng/random import room import vector @@ -496,3 +498,100 @@ pub fn get_reflecting_point( _, _ -> Error(Nil) } } + +type CoordinateEntry = + #(Coordinate, Float, option.Option(Coordinate)) + +/// Gets a path from 1 point to another in a dungeon +pub fn path_to( + dungeon: Dungeon, + from: vector.Vector, + to: vector.Vector, +) -> List(vector.Vector) { + let start = point_to_coordinate(from) + let initial_entry = #(start, 0.0, option.None) + path_to_helper( + dungeon, + point_to_coordinate(to), + priorityq.new(fn(a: CoordinateEntry, b: CoordinateEntry) { + float.compare(b.1, a.1) + }) + |> priorityq.push(initial_entry), + dict.from_list([#(start, initial_entry)]), + ) +} + +fn path_to_helper( + dungeon: Dungeon, + to: Coordinate, + worklist: priorityq.PriorityQueue(CoordinateEntry), + seen_so_far: dict.Dict(Coordinate, CoordinateEntry), +) -> List(vector.Vector) { + use <- bool.guard(priorityq.is_empty(worklist), []) + + let assert option.Some(#(t, cost, _)) = priorityq.peek(worklist) + let worklist = priorityq.pop(worklist) + + case t == to { + True -> list.reverse(path_from_costs(seen_so_far, to)) + False -> { + let #(worklist, seen_so_far) = + [ + room.Left, + room.Right, + room.Top, + room.Bottom, + room.TopLeft, + room.TopRight, + room.BottomLeft, + room.BottomRight, + ] + |> list.fold(#(worklist, seen_so_far), fn(acc, dir) { + let #(worklist, seen_so_far) = acc + let assert Ok(to_room) = dict.get(dungeon.rooms, t) + case room.is_navigable(to_room, dir) { + False -> #(worklist, seen_so_far) + True -> { + let next = next_room_indices(t.0, t.1, dir) + let next_cost = + cost + +. vector.distance( + vector.Vector(int.to_float(next.0), int.to_float(next.1), 0.0), + vector.Vector(int.to_float(to.0), int.to_float(to.1), 0.0), + ) + + case dict.get(seen_so_far, next) { + // we have a better path do not traverse + Ok(#(_, old_best, _)) if old_best <. next_cost -> #( + worklist, + seen_so_far, + ) + _ -> #( + worklist + |> priorityq.push(#(next, next_cost, option.Some(t))), + seen_so_far + |> dict.insert(next, #(next, next_cost, option.Some(t))), + ) + } + } + } + }) + + path_to_helper(dungeon, to, worklist, seen_so_far) + } + } +} + +fn path_from_costs( + seen_so_far: dict.Dict(Coordinate, CoordinateEntry), + end: Coordinate, +) -> List(vector.Vector) { + let assert Ok(#(_, _, prev)) = dict.get(seen_so_far, end) + case prev { + option.None -> [] + option.Some(prev) -> [ + coordinate_to_point(end), + ..path_from_costs(seen_so_far, prev) + ] + } +} diff --git a/src/enemy.gleam b/src/enemy.gleam index 352764c..261601d 100644 --- a/src/enemy.gleam +++ b/src/enemy.gleam @@ -2,7 +2,6 @@ import behavior_tree/behavior_tree import bullet.{type Bullet} import dungeon.{type Dungeon} import gleam/bool -import gleam/dict import gleam/float import gleam/int import gleam/list @@ -184,6 +183,105 @@ pub fn follow_path_behavior(inputs: BehaviorInput) -> BehaviorResult { } } +// Behavior that just succeeds if the enemy has line of sight and is in range of the player. +pub fn in_range_of_player_behavior(inputs: BehaviorInput) -> BehaviorResult { + let behavior_tree.BehaviorInput( + entity: enemy, + additional_inputs: Inputs(_, dungeon, player), + ) = inputs + + use <- bool.guard( + vector.distance(enemy.position, player.position) + >. int.to_float(dungeon.room_size / 2), + behavior_tree.BehaviorResult(False, enemy, AdditionalOutputs([])), + ) + + behavior_tree.BehaviorResult( + dungeon.can_move(dungeon, enemy.position, player.position), + enemy, + AdditionalOutputs([]), + ) +} + +// Simple behavior that marks the player as spotted. +pub fn spot_player_behavior(inputs: BehaviorInput) -> BehaviorResult { + let behavior_tree.BehaviorInput(entity: enemy, additional_inputs: _) = inputs + + behavior_tree.BehaviorResult( + True, + Enemy(..enemy, spotted_player: True), + AdditionalOutputs([]), + ) +} + +// Simple behavior that checks if the enemy spotted the player. +pub fn spotted_player_behavior(inputs: BehaviorInput) -> BehaviorResult { + let behavior_tree.BehaviorInput(entity: enemy, additional_inputs: _) = inputs + + behavior_tree.BehaviorResult( + enemy.spotted_player, + enemy, + AdditionalOutputs([]), + ) +} + +// Behavior that set's the enemy's path to the player. +// Always succeeds. +pub fn path_to_player_behavior(inputs: BehaviorInput) -> BehaviorResult { + let behavior_tree.BehaviorInput( + entity: enemy, + additional_inputs: Inputs(_, dungeon, player), + ) = inputs + + let path = dungeon.path_to(dungeon, enemy.position, player.position) + // Drop last path entry because player is in that room + let path = path |> list.take(list.length(path) - 1) + + let enemy = + Enemy(..enemy, path: path, last_path_updated: utils.now_in_milliseconds()) + + behavior_tree.BehaviorResult(True, enemy, AdditionalOutputs([])) +} + +// Simple behavior that moves the enemy toward the spotted player. +pub fn follow_player_behavior(inputs: BehaviorInput) -> BehaviorResult { + let behavior_tree.BehaviorInput( + entity: enemy, + additional_inputs: Inputs(enemies, dungeon, player), + ) = inputs + + let target = vector.vector_2d(player.position) + let whiskers = + create_whisker_points(enemy) + |> list.filter_map(dungeon.get_reflecting_point(dungeon, enemy.position, _)) + + let #(enemy, new_position) = + move_enemy( + enemy, + target, + dungeon.obstacles, + dungeon.pits, + enemies, + whiskers, + ) + let new_position = case + dungeon.can_move(dungeon, enemy.position, new_position) + { + True -> new_position + False -> + // Apply downward velocity but don't move forward + vector.Vector( + enemy.position.x, + enemy.position.y, + enemy.position.z +. enemy.velocity.z, + ) + } + + let enemy = Enemy(..enemy, position: new_position) |> steer_enemy(target) + + behavior_tree.BehaviorResult(True, enemy, AdditionalOutputs([])) +} + /// Represents an enemy to defeat. pub type Enemy { /// Represents an enemy to defeat. @@ -204,6 +302,8 @@ pub type Enemy { path: List(Vector), /// The last time the path was updated. last_path_updated: Int, + /// Can the enemy currently see the player. + spotted_player: Bool, /// The behavior that an enemy will follow on each tick. btree: BehaviorTree, ) @@ -239,11 +339,15 @@ pub fn new_enemy(initial_position: Vector) -> Enemy { max_health: max_enemy_health, path: [], last_path_updated: 0, + spotted_player: False, // TODO: actually make a behavior btree: behavior_tree.all( [ + // Look for player + sequence([in_range_of_player_behavior, spot_player_behavior]), // Update the enemy's path selector([ + sequence([spotted_player_behavior, path_to_player_behavior]), sequence([ selector([ behavior_tree.not(has_path_behavior), @@ -253,7 +357,10 @@ pub fn new_enemy(initial_position: Vector) -> Enemy { ]), ]), // Move the enemy - selector([sequence([has_path_behavior, follow_path_behavior])]), + selector([ + sequence([has_path_behavior, follow_path_behavior]), + sequence([spotted_player_behavior, follow_player_behavior]), + ]), ], default_output, output_to_input, diff --git a/test/enemy_test.gleam b/test/enemy_test.gleam index 2cb6862..2955f01 100644 --- a/test/enemy_test.gleam +++ b/test/enemy_test.gleam @@ -37,6 +37,7 @@ pub fn enemy_tests() { max_health: 100, path: [], last_path_updated: 0, + spotted_player: False, btree: dummy_btree, ) |> enemy.apply_gravity @@ -52,6 +53,7 @@ pub fn enemy_tests() { max_health: 100, path: [], last_path_updated: 0, + spotted_player: False, btree: dummy_btree, ) |> enemy.apply_gravity @@ -69,6 +71,7 @@ pub fn enemy_tests() { max_health: 100, path: [], last_path_updated: 0, + spotted_player: False, btree: dummy_btree, ) |> enemy.apply_damage(20) @@ -85,6 +88,7 @@ pub fn enemy_tests() { max_health: 100, path: [], last_path_updated: 0, + spotted_player: False, btree: dummy_btree, ) |> enemy.is_enemy_dead, @@ -99,6 +103,7 @@ pub fn enemy_tests() { max_health: 100, path: [], last_path_updated: 0, + spotted_player: False, btree: dummy_btree, ) |> enemy.is_enemy_dead, @@ -115,6 +120,7 @@ pub fn enemy_tests() { max_health: 100, path: [], last_path_updated: 0, + spotted_player: False, btree: dummy_btree, ), Vector(10.0, 0.0, 0.0), @@ -130,6 +136,7 @@ pub fn enemy_tests() { max_health: 100, path: [], last_path_updated: 0, + spotted_player: False, btree: dummy_btree, ), Vector(0.0, 0.0, 0.0), @@ -167,7 +174,20 @@ pub fn enemy_tests() { |> dict.insert( #(5, 1), room.initialize_unbounded_room() - |> room.set_navigable(room.Left, True), + |> room.set_navigable(room.Left, True) + |> room.set_navigable(room.Right, True), + ) + |> dict.insert( + #(6, 1), + room.initialize_unbounded_room() + |> room.set_navigable(room.Left, True) + |> room.set_navigable(room.Bottom, True), + ) + |> dict.insert( + #(6, 2), + room.initialize_unbounded_room() + |> room.set_navigable(room.Left, True) + |> room.set_navigable(room.Top, True), ) let dungeon = dungeon.Dungeon(rooms: rooms, pits: [], obstacles: []) @@ -245,5 +265,127 @@ pub fn enemy_tests() { Nil }), + it("in_range_of_player_behavior", fn() { + let enemy = enemy.new_enemy(Vector(150.0, 150.0, 0.0)) + + let behavior_tree.BehaviorResult(success, out_enemy, _) = + enemy.in_range_of_player_behavior(behavior_tree.BehaviorInput( + enemy, + enemy.Inputs( + [], + dungeon, + player.new_player(Vector(150.0, 150.0, 0.0)), + ), + )) + expect.to_be_true(success) + expect.to_equal(out_enemy, enemy) + + let enemy = enemy.new_enemy(Vector(110.0, 150.0, 0.0)) + + let behavior_tree.BehaviorResult(success, out_enemy, _) = + enemy.in_range_of_player_behavior(behavior_tree.BehaviorInput( + enemy, + enemy.Inputs( + [], + dungeon, + player.new_player(Vector(190.0, 150.0, 0.0)), + ), + )) + expect.to_be_false(success) + expect.to_equal(out_enemy, enemy) + + let enemy = enemy.new_enemy(Vector(250.0, 190.0, 0.0)) + + let behavior_tree.BehaviorResult(success, out_enemy, _) = + enemy.in_range_of_player_behavior(behavior_tree.BehaviorInput( + enemy, + enemy.Inputs( + [], + dungeon, + player.new_player(Vector(250.0, 210.0, 0.0)), + ), + )) + expect.to_be_true(success) + expect.to_equal(out_enemy, enemy) + + let enemy = enemy.new_enemy(Vector(150.0, 190.0, 0.0)) + + let behavior_tree.BehaviorResult(success, out_enemy, _) = + enemy.in_range_of_player_behavior(behavior_tree.BehaviorInput( + enemy, + enemy.Inputs( + [], + dungeon, + player.new_player(Vector(150.0, 210.0, 0.0)), + ), + )) + expect.to_be_false(success) + expect.to_equal(out_enemy, enemy) + }), + it("spot_player_behavior", fn() { + let enemy = enemy.new_enemy(Vector(0.0, 0.0, 0.0)) + + let behavior_tree.BehaviorResult(success, out_enemy, _) = + enemy.spot_player_behavior(behavior_tree.BehaviorInput( + enemy, + enemy.Inputs([], dungeon, player.new_player(Vector(0.0, 0.0, 0.0))), + )) + expect.to_be_true(success) + expect.to_be_true(out_enemy.spotted_player) + }), + it("spotted_player_behavior", fn() { + let enemy = enemy.new_enemy(Vector(0.0, 0.0, 0.0)) + + let behavior_tree.BehaviorResult(success, out_enemy, _) = + enemy.spotted_player_behavior(behavior_tree.BehaviorInput( + enemy, + enemy.Inputs([], dungeon, player.new_player(Vector(0.0, 0.0, 0.0))), + )) + expect.to_be_false(success) + expect.to_equal(out_enemy, enemy) + + let enemy = Enemy(..enemy, spotted_player: True) + + let behavior_tree.BehaviorResult(success, out_enemy, _) = + enemy.spotted_player_behavior(behavior_tree.BehaviorInput( + enemy, + enemy.Inputs([], dungeon, player.new_player(Vector(0.0, 0.0, 0.0))), + )) + expect.to_be_true(success) + expect.to_equal(out_enemy, enemy) + }), + it("path_to_player_behavior", fn() { + let enemy = enemy.new_enemy(Vector(150.0, 150.0, 0.0)) + + let behavior_tree.BehaviorResult(success, out_enemy, _) = + enemy.path_to_player_behavior(behavior_tree.BehaviorInput( + enemy, + enemy.Inputs( + [], + dungeon, + player.new_player(Vector(150.0, 150.0, 0.0)), + ), + )) + expect.to_be_true(success) + expect.to_equal(out_enemy.path, enemy.path) + + let enemy = enemy.new_enemy(Vector(450.0, 250.0, 0.0)) + + let behavior_tree.BehaviorResult(success, out_enemy, _) = + enemy.path_to_player_behavior(behavior_tree.BehaviorInput( + enemy, + enemy.Inputs( + [], + dungeon, + player.new_player(Vector(650.0, 250.0, 0.0)), + ), + )) + expect.to_be_true(success) + expect.to_equal(out_enemy.path, [ + Vector(450.0, 150.0, 0.0), + Vector(550.0, 150.0, 0.0), + Vector(650.0, 150.0, 0.0), + ]) + }), ]) }