├── .gitignore ├── ss.png ├── ss2.png ├── src ├── gamelog.rs ├── util.rs ├── map_indexing_system.rs ├── monster_ai_system.rs ├── melee_combat_system.rs ├── damage_system.rs ├── components.rs ├── player.rs ├── gui.rs ├── visibility_system.rs ├── main.rs └── map.rs ├── resources ├── Md_16x16.png ├── Yayo_16x16.png ├── Zilk_16x16.png ├── Kjammer_16x16.png └── Zilk_16x16og.png ├── README.md ├── Cargo.toml └── .github └── workflows └── rust.yml /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | Cargo.lock 3 | .DS_Store 4 | -------------------------------------------------------------------------------- /ss.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/carsin/miners/HEAD/ss.png -------------------------------------------------------------------------------- /ss2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/carsin/miners/HEAD/ss2.png -------------------------------------------------------------------------------- /src/gamelog.rs: -------------------------------------------------------------------------------- 1 | pub struct GameLog { 2 | pub entries: Vec, 3 | } 4 | -------------------------------------------------------------------------------- /resources/Md_16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/carsin/miners/HEAD/resources/Md_16x16.png -------------------------------------------------------------------------------- /resources/Yayo_16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/carsin/miners/HEAD/resources/Yayo_16x16.png -------------------------------------------------------------------------------- /resources/Zilk_16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/carsin/miners/HEAD/resources/Zilk_16x16.png -------------------------------------------------------------------------------- /resources/Kjammer_16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/carsin/miners/HEAD/resources/Kjammer_16x16.png -------------------------------------------------------------------------------- /resources/Zilk_16x16og.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/carsin/miners/HEAD/resources/Zilk_16x16og.png -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # miners 2 | An ASCII RPG Roguelike in Rust using bracket-lib & specs 3 | 4 | Dynamic lightmap 5 | ![Screen shot 2](ss2.png) 6 | 7 | FOV Shadowcasted lighting system 8 | ![Screen shot](ss.png) 9 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "miners" 3 | version = "0.1.0" 4 | authors = ["carsin "] 5 | edition = "2018" 6 | 7 | [dependencies] 8 | bracket-terminal = "0.8.1" 9 | bracket-lib = { version = "0.8.1", features = [ "specs" ] } 10 | specs = { version = "0.16.1", features = ["specs-derive"] } 11 | rand = "*" 12 | -------------------------------------------------------------------------------- /src/util.rs: -------------------------------------------------------------------------------- 1 | use std::cmp::{min, max}; 2 | 3 | pub fn clamp(value: i32, min_value: i32, max_value: i32) -> i32 { 4 | max(min_value, min(max_value, value)) 5 | } 6 | 7 | pub fn round_tie_up(value: f32) -> i32 { 8 | (value + 0.5).floor() as i32 9 | } 10 | 11 | pub fn round_tie_down(value: f32) -> i32 { 12 | (value - 0.5).ceil() as i32 13 | } 14 | -------------------------------------------------------------------------------- /.github/workflows/rust.yml: -------------------------------------------------------------------------------- 1 | name: Rust 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v2 19 | - name: Build 20 | run: cargo build --verbose 21 | - name: Run tests 22 | run: cargo test --verbose 23 | -------------------------------------------------------------------------------- /src/map_indexing_system.rs: -------------------------------------------------------------------------------- 1 | use specs::prelude::*; 2 | use super::{Map, Position, BlocksTile}; 3 | 4 | pub struct MapIndexingSystem {} 5 | 6 | impl<'a> System<'a> for MapIndexingSystem { 7 | type SystemData = ( WriteExpect<'a, Map>, 8 | ReadStorage<'a, Position>, 9 | ReadStorage<'a, BlocksTile>, 10 | Entities<'a>, 11 | ); 12 | 13 | 14 | fn run(&mut self, data: Self::SystemData) { 15 | let (mut map, position, blockers, entities) = data; 16 | 17 | map.populate_blocked(); 18 | map.clear_entity_content(); 19 | for (entity, position) in (&entities, &position).join() { 20 | let idx = map.xy_idx(position.x, position.y); 21 | 22 | // if the entity blocks, update the blocking list 23 | let _p: Option<&BlocksTile> = blockers.get(entity); 24 | if let Some(_p) = _p { 25 | map.tile_blocked[idx] = true; 26 | } 27 | 28 | // push the entity to the appropriate index slot in the map 29 | // it's a Copy type, so we don't need to clone it 30 | map.tile_entity[idx].push(entity); 31 | 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/monster_ai_system.rs: -------------------------------------------------------------------------------- 1 | use specs::prelude::*; 2 | use super::{Viewshed, Position, Monster, Name, MeleeAttacking, RunState}; 3 | 4 | pub struct MonsterAI {} 5 | 6 | impl<'a> System<'a> for MonsterAI { 7 | type SystemData = ( ReadExpect<'a, Position>, 8 | ReadExpect<'a, Entity>, 9 | ReadExpect<'a, RunState>, 10 | Entities<'a>, 11 | WriteStorage<'a, Viewshed>, 12 | ReadStorage<'a, Position>, 13 | ReadStorage<'a, Monster>, 14 | ReadStorage<'a, Name>, 15 | WriteStorage<'a, MeleeAttacking> 16 | ); 17 | 18 | 19 | fn run(&mut self, data: Self::SystemData) { 20 | let (player_pos, player_entity, runstate, entities, mut viewshed, position, monster, name, mut melee_attack) = data; 21 | 22 | if *runstate != RunState::MonsterTurn { return; } 23 | 24 | for (entity, viewshed, _position, _monster, name) in (&entities, &mut viewshed, &position, &monster, &name).join() { 25 | if viewshed.visible_tiles.contains(&*player_pos) { 26 | println!("{}", format!("{}: DON'T KILL ME PLZ IM USELESS", name.name)); 27 | melee_attack.insert(entity, MeleeAttacking { target: *player_entity }).expect("Adding attack target failed."); 28 | } 29 | // viewshed.dirty = true; 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/melee_combat_system.rs: -------------------------------------------------------------------------------- 1 | use specs::prelude::*; 2 | use super::{CombatStats, MeleeAttacking, Name, SufferDamage, GameLog}; 3 | 4 | pub struct MeleeCombatSystem {} 5 | 6 | impl<'a> System<'a> for MeleeCombatSystem { 7 | type SystemData = ( Entities<'a>, 8 | WriteExpect<'a, GameLog>, 9 | WriteStorage<'a, MeleeAttacking>, 10 | ReadStorage<'a, Name>, 11 | ReadStorage<'a, CombatStats>, 12 | WriteStorage<'a, SufferDamage>, 13 | ); 14 | 15 | 16 | fn run(&mut self, data: Self::SystemData) { 17 | let (entities, mut log, mut meleeing, names, combat_stats, mut inflict_damage) = data; 18 | 19 | for (_entity, meleeing, name, attacker_stats) in (&entities, &meleeing, &names, &combat_stats).join() { 20 | if attacker_stats.hp > 0 { // check if attacker is alive 21 | let target_stats = combat_stats.get(meleeing.target).unwrap(); 22 | if target_stats.hp > 0 { // check if target is alive 23 | let target_name = names.get(meleeing.target).unwrap(); 24 | 25 | let damage = i32::max(0, attacker_stats.damage - target_stats.armor); 26 | 27 | if damage == 0 { 28 | log.entries.push(format!("{} attacks can't get through {}'s armor.", &name.name, &target_name.name)); 29 | } else { 30 | log.entries.push(format!("{} attacks {} for {} dmg", &name.name, &target_name.name, damage)); 31 | SufferDamage::new_damage(&mut inflict_damage, meleeing.target, damage); 32 | } 33 | } 34 | } 35 | } 36 | meleeing.clear(); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/damage_system.rs: -------------------------------------------------------------------------------- 1 | use specs::prelude::*; 2 | use super::{CombatStats, SufferDamage, Player, Name, GameLog}; 3 | 4 | pub struct DamageSystem {} 5 | 6 | impl<'a> System<'a> for DamageSystem { 7 | type SystemData = ( WriteStorage<'a, CombatStats>, 8 | WriteStorage<'a, SufferDamage>, 9 | ); 10 | 11 | 12 | fn run(&mut self, data: Self::SystemData) { 13 | let (mut stats, mut damage) = data; 14 | 15 | for (mut stats, damage) in (&mut stats, &damage).join() { 16 | stats.hp -= damage.amount.iter().sum::(); // iterate through all damage, not just one per turn 17 | } 18 | damage.clear(); 19 | } 20 | 21 | } 22 | 23 | pub fn remove_dead(world: &mut World) { 24 | let mut dead: Vec = Vec::new(); // initialize empty array to store all dead entities 25 | // new scope to avoid borrow 26 | { 27 | let combat_stats = world.read_storage::(); 28 | let players = world.read_storage::(); 29 | let names = world.read_storage::(); 30 | let entities = world.entities(); 31 | let mut log = world.write_resource::(); 32 | // loop through entities with hp 33 | for (entity, stats) in (&entities, &combat_stats).join() { 34 | if stats.hp < 1 { 35 | let player = players.get(entity); 36 | match player { 37 | None => { 38 | let victim_name = names.get(entity); 39 | if let Some(victim_name) = victim_name { 40 | log.entries.push(format!("{} died", &victim_name.name)); 41 | } 42 | dead.push(entity); 43 | }, 44 | 45 | Some(_) => { 46 | let death_text = String::from("You have perished."); 47 | println!("{}", death_text); 48 | log.entries.push(death_text); 49 | } 50 | } 51 | } 52 | } 53 | } 54 | 55 | // loop through dead entites and clear them 56 | for entity in dead { 57 | world.delete_entity(entity).expect("Unable to remove dead entity"); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/components.rs: -------------------------------------------------------------------------------- 1 | use bracket_terminal::prelude::RGB; 2 | use specs::{prelude::*, Component, VecStorage}; 3 | 4 | #[derive(PartialEq, Copy, Clone, Component, Debug)] 5 | #[storage(VecStorage)] 6 | pub struct Position { 7 | pub x: i32, 8 | pub y: i32, 9 | } 10 | 11 | impl Position { 12 | pub fn new(x: i32, y: i32) -> Self { 13 | Self {x, y} 14 | } 15 | } 16 | 17 | #[derive(Component, Debug)] 18 | #[storage(VecStorage)] 19 | pub struct Renderable { 20 | pub glyph: char, 21 | pub fg: RGB, 22 | pub bg: RGB, 23 | } 24 | 25 | #[derive(Component, Debug)] 26 | #[storage(VecStorage)] 27 | pub struct Player {} 28 | 29 | // generates lists of tiles visible from position and their light levels. 30 | #[derive(Component, Debug)] 31 | #[storage(VecStorage)] 32 | pub struct Viewshed { 33 | pub visible_tiles: Vec, // positions relative to algorithm that are visible 34 | pub light_levels: Vec>, // light levels 35 | pub emitter: Option, // determines if entity emits light, and if so the maximum strength of 1.0 to 0.0 36 | pub range: f32, // changes how deep the shadowcasting algorithm goes. affects fov viewrange & lightshed 37 | // pub max_strength: f32, // changes the light level at the source and thus how gradual the light shift is 38 | pub dirty: bool, // has game changed (player moved)? 39 | } 40 | 41 | #[derive(Component, Debug)] 42 | #[storage(VecStorage)] 43 | pub struct Monster {} 44 | 45 | #[derive(Component, Debug)] 46 | #[storage(VecStorage)] 47 | pub struct Name { 48 | pub name: String, 49 | } 50 | 51 | #[derive(Component, Debug)] 52 | #[storage(VecStorage)] 53 | pub struct BlocksTile {} 54 | 55 | #[derive(Component, Debug)] 56 | #[storage(VecStorage)] 57 | pub struct CombatStats { 58 | pub max_hp: i32, 59 | pub hp: i32, 60 | pub armor: i32, 61 | pub damage: i32, 62 | } 63 | 64 | #[derive(Component, Debug, Clone)] 65 | #[storage(VecStorage)] 66 | pub struct MeleeAttacking { 67 | pub target: Entity, 68 | } 69 | 70 | #[derive(Component, Debug)] 71 | pub struct SufferDamage { 72 | pub amount : Vec 73 | } 74 | 75 | impl SufferDamage { 76 | pub fn new_damage(store: &mut WriteStorage, victim: Entity, amount: i32) { 77 | if let Some(recipient) = store.get_mut(victim) { 78 | recipient.amount.push(amount); 79 | } else { 80 | let dmg = SufferDamage { amount: vec![amount] }; 81 | store.insert(victim, dmg).expect("Unable to insert damage"); 82 | } 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/player.rs: -------------------------------------------------------------------------------- 1 | use specs::{WorldExt, World, prelude::*}; 2 | use bracket_terminal::prelude::*; 3 | use super::{Direction, Player, Position, Map, util, Viewshed, Game, RunState, CombatStats, MeleeAttacking}; 4 | 5 | pub fn move_player(dir: Direction, world: &mut World) { 6 | let mut positions = world.write_storage::(); 7 | let mut player = world.write_storage::(); 8 | let mut viewsheds = world.write_storage::(); 9 | let mut combat_stats = world.write_storage::(); 10 | let mut melee_attack = world.write_storage::(); 11 | 12 | let map = world.fetch::(); 13 | let entities = world.entities(); 14 | 15 | // convert move direction to dx & dy 16 | let (delta_x, delta_y) = match dir { 17 | Direction::North => { (0, -1) }, 18 | Direction::South => { (0, 1) } 19 | Direction::East => { (1, 0) } 20 | Direction::West => { (-1, 0) } 21 | }; 22 | 23 | // run ecs system 24 | for (entity, _player, pos, viewshed) in (&entities, &mut player, &mut positions, &mut viewsheds).join() { 25 | if pos.x + delta_x < 1 || pos.x + delta_x > map.width as i32 - 1 || pos.y + delta_y < 1 || pos.y + delta_y > map.height as i32 - 1 { return; } // don't try to attack outside map 26 | let destination_idx = map.xy_idx(pos.x + delta_x, pos.y + delta_y); 27 | 28 | // check if there is an entity in destination tile & attack it 29 | for potential_target in map.tile_entity[destination_idx].iter() { 30 | let target = combat_stats.get(*potential_target); 31 | if let Some(_target) = target { 32 | melee_attack.insert(entity, MeleeAttacking { target: *potential_target }).expect("Adding attack target failed."); 33 | return; // don't move if player attacked attacked 34 | } 35 | } 36 | 37 | // move if the destination tile isn't blocked 38 | if !map.tile_blocked[destination_idx] { 39 | pos.x = util::clamp(pos.x + delta_x, 0, (map.width - 1) as i32); 40 | pos.y = util::clamp(pos.y + delta_y, 0, (map.height - 1) as i32); 41 | 42 | let mut player_pos = world.write_resource::(); 43 | player_pos.x = pos.x; 44 | player_pos.y = pos.y; 45 | viewshed.dirty = true; 46 | } 47 | } 48 | } 49 | 50 | pub fn input(game: &mut Game, ctx: &mut BTerm) -> RunState { 51 | match ctx.key { 52 | None => { return RunState::AwaitingInput } 53 | Some(key) => match key { 54 | VirtualKeyCode::K | VirtualKeyCode::W => move_player(Direction::North, &mut game.world), 55 | VirtualKeyCode::J | VirtualKeyCode::S => move_player(Direction::South, &mut game.world), 56 | VirtualKeyCode::L | VirtualKeyCode::D => move_player(Direction::East, &mut game.world), 57 | VirtualKeyCode::H | VirtualKeyCode::A => move_player(Direction::West, &mut game.world), 58 | _ => { return RunState::AwaitingInput } 59 | } 60 | } 61 | RunState::PlayerTurn 62 | } 63 | -------------------------------------------------------------------------------- /src/gui.rs: -------------------------------------------------------------------------------- 1 | use bracket_terminal::prelude::{RGB, BTerm}; 2 | use super::{CombatStats, Player, GameLog, Map, Name, Position, BASE_LIGHT_LEVEL}; 3 | use specs::prelude::*; 4 | 5 | pub fn draw_ui(world: &World, ctx: &mut BTerm) { 6 | let log = world.fetch::(); 7 | ctx.draw_box(0, 43, 79, 6, RGB::from_f32(0.7, 0.7, 0.7), RGB::from_f32(0.0, 0.0, 0.0)); 8 | 9 | let mut y = 44; 10 | // TODO: Improve look of this 11 | for msg in log.entries.iter().rev() { 12 | if y < 49 { ctx.print_color(2, y, RGB::from_f32(0.8, 0.8, 0.8), RGB::from_f32(0.0, 0.0, 0.0), msg); } 13 | y += 1; 14 | } 15 | 16 | // render HP 17 | let combat_stats = world.read_storage::(); 18 | let players = world.read_storage::(); 19 | for (_player, stats) in (&players, &combat_stats).join() { 20 | let health = format!(" HP: {} / {} ", stats.hp, stats.max_hp); 21 | ctx.print_color(12, 43, RGB::from_f32(0.8, 0.8, 0.0), RGB::from_f32(0.0, 0.0, 0.0), &health); 22 | ctx.draw_bar_horizontal(28, 43, 51, stats.hp, stats.max_hp, RGB::from_f32(0.9, 0.0, 0.0), RGB::from_f32(0.0, 0.0, 0.0)); 23 | } 24 | 25 | let mouse_pos = ctx.mouse_pos(); 26 | ctx.set_bg(mouse_pos.0, mouse_pos.1, RGB::from_f32(0.2, 0.2, 0.2)); 27 | draw_tooltips(world, ctx); 28 | } 29 | 30 | fn draw_tooltips(world: &World, ctx: &mut BTerm) { 31 | let map = world.fetch::(); 32 | let names = world.read_storage::(); 33 | let positions = world.read_storage::(); 34 | 35 | let mouse_pos = ctx.mouse_pos(); 36 | if mouse_pos.0 >= map.width as i32 || mouse_pos.1 >= map.height as i32 { return; } 37 | let mut tooltip : Vec = Vec::new(); 38 | for (name, position) in (&names, &positions).join() { 39 | let idx = map.xy_idx(position.x, position.y); 40 | if position.x == mouse_pos.0 && position.y == mouse_pos.1 && map.light_levels[idx] > Some(BASE_LIGHT_LEVEL) { 41 | tooltip.push(name.name.to_string()); 42 | } 43 | } 44 | 45 | if !tooltip.is_empty() { 46 | let mut width :i32 = 0; 47 | for s in tooltip.iter() { 48 | if width < s.len() as i32 { width = s.len() as i32; } 49 | } 50 | width += 3; 51 | 52 | if mouse_pos.0 > 40 { 53 | let arrow_pos = Position::new(mouse_pos.0 - 2, mouse_pos.1); 54 | let left_x = mouse_pos.0 - width; 55 | let mut y = mouse_pos.1; 56 | for s in tooltip.iter() { 57 | ctx.print_color(left_x, y, RGB::from_u8(255, 255, 255), RGB::from_u8(100, 100, 100), s); 58 | let padding = (width - s.len() as i32)-1; 59 | for i in 0..padding { 60 | ctx.print_color(arrow_pos.x - i, y, RGB::from_u8(255, 255, 255), RGB::from_u8(100, 100, 100), &" ".to_string()); 61 | } 62 | y += 1; 63 | } 64 | ctx.print_color(arrow_pos.x, arrow_pos.y, RGB::from_u8(255, 255, 255), RGB::from_u8(100, 100, 100), &"->".to_string()); 65 | } else { 66 | let arrow_pos = Position::new(mouse_pos.0 + 1, mouse_pos.1); 67 | let left_x = mouse_pos.0 +3; 68 | let mut y = mouse_pos.1; 69 | for s in tooltip.iter() { 70 | ctx.print_color(left_x + 1, y, RGB::from_u8(255, 255, 255), RGB::from_u8(100, 100, 100), s); 71 | let padding = (width - s.len() as i32)-1; 72 | for i in 0..padding { 73 | ctx.print_color(arrow_pos.x + 1 + i, y, RGB::from_u8(255, 255, 255), RGB::from_u8(100, 100, 100), &" ".to_string()); 74 | } 75 | y += 1; 76 | } 77 | ctx.print_color(arrow_pos.x, arrow_pos.y, RGB::from_u8(255, 255, 255), RGB::from_u8(100, 100, 100), &"<-".to_string()); 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/visibility_system.rs: -------------------------------------------------------------------------------- 1 | use specs::prelude::*; 2 | use super::{Viewshed, Position, Map, Direction, util, map::TileType, BASE_LIGHT_LEVEL}; 3 | 4 | struct Quadrant { 5 | origin: Position, 6 | dir: Direction, 7 | } 8 | 9 | impl Quadrant { 10 | // converts position relative to quadrant to absolute map position 11 | fn map_pos(&self, old_pos: &Position) -> Position { 12 | let (x, y) = match self.dir { 13 | Direction::North => (self.origin.x + old_pos.x, self.origin.y - old_pos.y), 14 | Direction::South => (self.origin.x + old_pos.x, self.origin.y + old_pos.y), 15 | Direction::East => (self.origin.x + old_pos.y, self.origin.y + old_pos.x), 16 | Direction::West => (self.origin.x - old_pos.y, self.origin.y - old_pos.x) 17 | }; 18 | 19 | Position { x, y } 20 | } 21 | } 22 | 23 | struct QuadrantRow { 24 | depth: i32, 25 | start_slope: f32, 26 | end_slope: f32, 27 | } 28 | 29 | impl QuadrantRow { 30 | fn tiles(&self) -> Vec { 31 | let mut tiles = vec![]; 32 | let min_x = util::round_tie_up(self.depth as f32 * self.start_slope); 33 | let max_x = util::round_tie_down(self.depth as f32 * self.end_slope); 34 | 35 | for x in min_x..max_x + 1 { 36 | tiles.push(Position { x, y: self.depth }); 37 | } 38 | 39 | tiles 40 | } 41 | 42 | pub fn next(&self) -> Self { 43 | Self { 44 | depth: self.depth + 1, 45 | start_slope: self.start_slope, 46 | end_slope: self.end_slope, 47 | } 48 | } 49 | } 50 | 51 | pub struct VisibilitySystem {} 52 | 53 | impl<'a> System<'a> for VisibilitySystem { 54 | type SystemData = ( WriteExpect<'a, Map>, 55 | Entities<'a>, 56 | WriteStorage<'a, Viewshed>, 57 | WriteStorage<'a, Position>, 58 | ); 59 | 60 | // runs for entities with viewshed & position components 61 | fn run(&mut self, data: Self::SystemData) { 62 | let (mut map, entities, mut viewshed, position) = data; 63 | for (_entity, viewshed, position) in (&entities, &mut viewshed, &position).join() { 64 | // update viewshed if game has changed 65 | if viewshed.dirty { 66 | viewshed.dirty = false; 67 | // Before clearing the viewshed's tiles, loop through this entity's previous iteration 68 | // of this system and only update the map's light array at those positions. 69 | for (i, pos) in viewshed.visible_tiles.iter().enumerate() { 70 | let idx = map.xy_idx(pos.x, pos.y); 71 | map.light_levels[idx] = match map.light_levels[idx] { 72 | None => None, // if tile hasn't been revealed, keep it set to none 73 | Some(level) => Some(level - viewshed.light_levels[i].unwrap_or(0.0)), // remove previous light level 74 | }; 75 | } 76 | 77 | let mut shadow_data = shadowcast(Position { x: position.x, y: position.y }, viewshed.range, viewshed.emitter, &*map); 78 | shadow_data.0.retain(|p| p.x >= 0 && p.x < map.width as i32 && p.y >= 0 && p.y < map.height as i32 ); // prune everything not within map bounds 79 | viewshed.visible_tiles = shadow_data.0; // store entities visible tiles (useful for FOV) 80 | viewshed.light_levels = shadow_data.1; // store light levels based on the depth (unused if entity isn't an emitter) 81 | 82 | // set light levels in map 83 | if let Some(_) = viewshed.emitter { 84 | for (i, map_pos) in viewshed.visible_tiles.iter().enumerate() { 85 | let idx = map.xy_idx(map_pos.x, map_pos.y); // converts algorithm coords to maps 86 | // Add light level to global light map 87 | map.light_levels[idx] = Some(viewshed.light_levels[i].unwrap_or(0.0) + map.light_levels[idx].unwrap_or(0.0)); 88 | } 89 | } 90 | } 91 | } 92 | } 93 | } 94 | 95 | // returns two lists: 96 | // one of the positions of all visible tiles from origin, 97 | // and another of those position's light levels (based on algorithm row depth) 98 | fn shadowcast(origin: Position, range: f32, emitter: Option, map: &Map) -> (Vec, Vec>) { 99 | let mut visible_tiles: Vec = vec![origin]; 100 | let mut light_levels: Vec> = vec![]; 101 | 102 | // light up origin tile as bright as max_strength 103 | if let Some(max_strength) = emitter { 104 | light_levels.push(Some(max_strength)); 105 | } 106 | 107 | // iterates through all 4 directions (NESW) 108 | let dirs = Direction::iterator(); 109 | for dir in dirs { 110 | let quadrant = Quadrant { origin, dir }; 111 | let first_row = QuadrantRow { 112 | depth: 1, 113 | start_slope: -1.0, 114 | end_slope: 1.0, 115 | }; 116 | 117 | let mut rows = vec![first_row]; 118 | while !rows.is_empty() { 119 | let mut current_row = rows.pop().unwrap(); 120 | let mut prev_tile: Option = None; 121 | let mut prev_tiletype: Option = None; 122 | 123 | for curr_tile in current_row.tiles() { 124 | prev_tiletype = get_tiletype(&map, &prev_tile, &quadrant); 125 | let curr_tiletype = get_tiletype(&map, &Some(curr_tile), &quadrant); 126 | 127 | if curr_tiletype == Some(TileType::Wall) || is_symmetric(¤t_row, &curr_tile) { 128 | if !visible_tiles.contains(&quadrant.map_pos(&curr_tile)) { 129 | // Add to visible tiles 130 | visible_tiles.push(quadrant.map_pos(&curr_tile)); 131 | // if an emitter, change light_levels 132 | if let Some(max_strength) = emitter { 133 | // calculate light level 134 | let light_level = max_strength - ((current_row.depth as f32 - 1.0) * max_strength) / range; 135 | light_levels.push(Some(BASE_LIGHT_LEVEL.max(light_level))); // ensures light level is higher than base light 136 | } 137 | } 138 | } 139 | 140 | if prev_tiletype == Some(TileType::Wall) && curr_tiletype == Some(TileType::Floor) { 141 | current_row.start_slope = slope(curr_tile); 142 | } 143 | 144 | if prev_tiletype == Some(TileType::Floor) && curr_tiletype == Some(TileType::Wall) { 145 | let mut next_row = current_row.next(); 146 | next_row.end_slope = slope(curr_tile); 147 | rows.push(next_row); 148 | } 149 | prev_tile = Some(curr_tile); 150 | prev_tiletype = get_tiletype(&map, &prev_tile, &quadrant); 151 | } 152 | 153 | if prev_tiletype == Some(TileType::Floor) { 154 | rows.push(current_row.next()); 155 | } 156 | 157 | if current_row.depth >= range as i32 { 158 | break; 159 | } 160 | } 161 | } 162 | (visible_tiles, light_levels) 163 | } 164 | 165 | fn is_symmetric(row: &QuadrantRow, tile: &Position) -> bool { 166 | tile.x as f32 >= row.depth as f32 * row.start_slope && tile.x as f32 <= row.depth as f32 * row.end_slope 167 | } 168 | 169 | fn slope(tile: Position) -> f32 { 170 | (2 * tile.x - 1) as f32 / (2 * tile.y) as f32 171 | } 172 | 173 | fn get_tiletype(map: &Map, tile: &Option, quadrant: &Quadrant) -> Option { 174 | match tile { 175 | None => None, 176 | Some(pos) => { 177 | let pos = quadrant.map_pos(pos); 178 | let idx = map.xy_idx(pos.x, pos.y); 179 | Some(map.tiles[idx]) 180 | } 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use bracket_terminal::prelude::*; 2 | use specs::prelude::*; 3 | use rand::Rng; 4 | 5 | use components::*; 6 | use map::{Direction, Map}; 7 | use visibility_system::VisibilitySystem; 8 | use monster_ai_system::MonsterAI; 9 | use map_indexing_system::MapIndexingSystem; 10 | use melee_combat_system::MeleeCombatSystem; 11 | use damage_system::DamageSystem; 12 | use gamelog::GameLog; 13 | 14 | mod components; 15 | mod map; 16 | mod player; 17 | mod util; 18 | mod visibility_system; 19 | mod monster_ai_system; 20 | mod map_indexing_system; 21 | mod melee_combat_system; 22 | mod damage_system; 23 | mod gui; 24 | mod gamelog; 25 | 26 | const GAME_WIDTH: usize = 80; 27 | const GAME_HEIGHT: usize = 50; 28 | 29 | const MAP_WIDTH: usize = GAME_WIDTH; 30 | const MAP_HEIGHT: usize = GAME_HEIGHT - 7; 31 | 32 | const BASE_LIGHT_LEVEL: f32 = 0.0; 33 | 34 | #[derive(PartialEq, Copy, Clone)] 35 | pub enum RunState { 36 | PreRun, 37 | AwaitingInput, 38 | PlayerTurn, 39 | MonsterTurn, 40 | } 41 | 42 | pub struct Game { 43 | pub world: World, 44 | } 45 | 46 | impl Game { 47 | fn run_systems(&mut self) { 48 | let mut visibility = VisibilitySystem{}; 49 | visibility.run_now(&self.world); 50 | let mut monsters = MonsterAI{}; 51 | monsters.run_now(&self.world); 52 | let mut mapindex = MapIndexingSystem{}; 53 | mapindex.run_now(&self.world); 54 | let mut melee_combat = MeleeCombatSystem{}; 55 | melee_combat.run_now(&self.world); 56 | let mut damage = DamageSystem{}; 57 | damage.run_now(&self.world); 58 | 59 | // Apply changes to World 60 | self.world.maintain(); 61 | } 62 | } 63 | 64 | impl GameState for Game { 65 | fn tick(&mut self, ctx: &mut BTerm) { 66 | // Reset console for next render 67 | ctx.cls(); 68 | let mut new_run_state; 69 | 70 | // get current run state 71 | { 72 | let run_state = self.world.fetch::(); 73 | new_run_state = *run_state; 74 | } 75 | 76 | // run according to run state 77 | match new_run_state { 78 | RunState::PreRun => { 79 | self.run_systems(); 80 | new_run_state = RunState::AwaitingInput; 81 | }, 82 | 83 | RunState::AwaitingInput => { 84 | new_run_state = player::input(self, ctx); 85 | }, 86 | 87 | RunState::PlayerTurn => { 88 | self.run_systems(); 89 | new_run_state = RunState::MonsterTurn; 90 | }, 91 | 92 | RunState::MonsterTurn => { 93 | self.run_systems(); 94 | new_run_state = RunState::AwaitingInput; 95 | }, 96 | } 97 | 98 | // update run state 99 | { 100 | let mut run_writer = self.world.write_resource::(); 101 | *run_writer = new_run_state; 102 | } 103 | 104 | // Remove dead entites 105 | damage_system::remove_dead(&mut self.world); 106 | 107 | // Render map 108 | let map = self.world.fetch::(); 109 | map.render(ctx); 110 | 111 | // Render entities 112 | let positions = self.world.read_storage::(); 113 | let renderables = self.world.read_storage::(); 114 | 115 | for (position, entity) in (&positions, &renderables).join() { 116 | let idx = map.xy_idx(position.x, position.y); 117 | if let Some(light_level) = map.light_levels[idx] { 118 | // TODO: Change to if in player FOV 119 | // only render if entity is lit 120 | if light_level > BASE_LIGHT_LEVEL { 121 | let fg = entity.fg.to_rgba(light_level); 122 | let bg = entity.bg.to_rgba(light_level); 123 | ctx.print_color(position.x, position.y, fg, bg, entity.glyph); 124 | } 125 | } 126 | } 127 | ctx.print_color(0, 0, RGB::named(WHITE), RGB::named(BLACK), &format!("{} fps", ctx.fps as u32)); // Render FPS 128 | gui::draw_ui(&self.world, ctx); 129 | } 130 | } 131 | 132 | // Options: Kjammer_16x16, Md_16x16, Yayo16x16, Zilk16x16 133 | bracket_terminal::embedded_resource!(TILE_FONT, "../resources/Zilk_16x16.png"); 134 | 135 | fn main() -> BError { 136 | bracket_terminal::link_resource!(TILE_FONT, "resources/Zilk_16x16.png"); 137 | let mut rng = rand::thread_rng(); 138 | let context = BTermBuilder::new() 139 | .with_tile_dimensions(16, 16) 140 | .with_dimensions(GAME_WIDTH, GAME_HEIGHT) 141 | .with_font("Zilk_16x16.png", 16, 16) 142 | .with_title("miners !dwmf") 143 | .with_simple_console(GAME_WIDTH, GAME_HEIGHT, "Zilk_16x16.png") 144 | // .with_automatic_console_resize(true) 145 | .build()?; 146 | 147 | let mut game: Game = Game { 148 | world: World::new(), 149 | }; 150 | 151 | game.world.register::(); 152 | game.world.register::(); 153 | game.world.register::(); 154 | game.world.register::(); 155 | game.world.register::(); 156 | game.world.register::(); 157 | game.world.register::(); 158 | game.world.register::(); 159 | game.world.register::(); 160 | game.world.register::(); 161 | 162 | let mut map = Map::new(MAP_WIDTH, MAP_HEIGHT); 163 | 164 | let room_count: usize = 10; 165 | let min_room_size: usize = 4; 166 | let max_room_size: usize = 10; 167 | 168 | map.generate_map_rooms_and_corridors(room_count, min_room_size, max_room_size); 169 | 170 | // Create player 171 | let (player_x, player_y) = map.rooms[0].center(); 172 | let player_entity = game.world.create_entity() 173 | .with(Position { x: player_x, y: player_y }) 174 | .with(Renderable { 175 | glyph: '☺', 176 | fg: RGB::from_f32(0.9, 0.9, 0.9), 177 | bg: RGB::from_f32(0.1, 0.1, 0.1), 178 | }) 179 | .with(Player {}) 180 | .with(Viewshed { visible_tiles: vec![], light_levels: vec![], emitter: Some(1.0), range: 5.0, dirty: true }) 181 | .with(Name { name: String::from("Player") }) 182 | .with(CombatStats { max_hp: 30, hp: 30, armor: 0, damage: 5 }) 183 | .build(); 184 | 185 | let mut zombie_count = 0; 186 | for room in map.rooms.iter().skip(1) { 187 | let (x, y) = room.center(); 188 | // 50/50 to spawn torch or monster 189 | if rng.gen::() { 190 | // spawn monster 191 | zombie_count += 1; 192 | game.world.create_entity() 193 | .with(Position { x: x - 1, y: y + 1 }) 194 | .with(Renderable { 195 | glyph: 'z', 196 | fg: RGB::from_f32(0.1, 0.5, 0.1), 197 | bg: RGB::from_f32(0.1, 0.1, 0.1), 198 | }) 199 | .with(Viewshed { visible_tiles: vec![], light_levels: vec![], emitter: None, range: 1.0, dirty: true }) 200 | .with(Monster {}) 201 | .with(Name { name: format!("Zombie #{}", zombie_count) }) 202 | .with(BlocksTile {}) 203 | .with(CombatStats { max_hp: 10, hp: 10, armor: 0, damage: 1 }) 204 | .build(); 205 | } else { 206 | game.world.create_entity() 207 | .with(Position { x, y }) 208 | .with(Renderable { 209 | glyph: 'i', 210 | fg: RGB::from_f32(1.0, 0.6, 0.0), 211 | bg: RGB::from_f32(0.1, 0.1, 0.1), 212 | }) 213 | .with(Name { name: String::from("Torch")}) 214 | .with(Viewshed { visible_tiles: vec![], light_levels: vec![], emitter: Some(0.6), range: 6.0, dirty: true }) 215 | .build(); 216 | } 217 | } 218 | 219 | game.world.insert(GameLog { entries: vec![String::from("Welcome to mine.rs")] }); 220 | game.world.insert(map); 221 | game.world.insert(player_entity); 222 | game.world.insert(Position::new(player_x, player_y)); 223 | game.world.insert(RunState::PreRun); 224 | 225 | // Call into bracket_terminal to run the main loop. This handles rendering, and calls back into State's tick function every cycle. The box is needed to work around lifetime handling. 226 | main_loop(context, game) 227 | } 228 | -------------------------------------------------------------------------------- /src/map.rs: -------------------------------------------------------------------------------- 1 | use bracket_lib::prelude::*; 2 | use specs::prelude::*; 3 | 4 | use std::cmp::{max, min}; 5 | use rand::Rng; 6 | 7 | #[derive(Copy, Clone)] 8 | pub enum Direction { 9 | North, South, East, West 10 | } 11 | 12 | impl Direction { 13 | pub fn iterator() -> impl Iterator { 14 | [Direction::North, Direction::East, Direction::South, Direction::West].iter().copied() 15 | } 16 | } 17 | 18 | #[derive(PartialEq, Copy, Clone)] 19 | pub enum TileType { 20 | Floor, Wall 21 | } 22 | 23 | impl TileType { 24 | pub fn get_data(self) -> TileData { 25 | match self { 26 | TileType::Floor => { 27 | TileData { 28 | glyph: '.', 29 | base_fg: RGB::from_f32(0.3, 0.3, 0.3), 30 | base_bg: RGB::from_f32(0.1, 0.1, 0.1), 31 | blocks_movement: false, 32 | } 33 | }, 34 | 35 | TileType::Wall => { 36 | TileData { 37 | glyph: '#', 38 | base_fg: RGB::from_f32(0.2, 0.2, 0.2), 39 | base_bg: RGB::from_f32(0.1, 0.1, 0.1), 40 | blocks_movement: true, 41 | } 42 | } 43 | } 44 | } 45 | } 46 | 47 | 48 | pub struct TileData { 49 | pub glyph: char, 50 | pub base_fg: RGB, // initialize to RGB as we convert to RGBA when rendering to get light level effect 51 | pub base_bg: RGB, // initialize to RGB as we convert to RGBA when rendering to get light level effect 52 | pub blocks_movement: bool, 53 | } 54 | 55 | #[derive(Debug)] 56 | pub struct Room { 57 | pub x1: i32, 58 | pub x2: i32, 59 | pub y1: i32, 60 | pub y2: i32, 61 | } 62 | 63 | impl Room { 64 | pub fn new(x: i32, y: i32, w: usize, h: usize) -> Self { 65 | Self {x1: x, y1: y, x2: x + w as i32, y2: y + h as i32} 66 | } 67 | 68 | pub fn overlaps_with(&self, other: &Room) -> bool { 69 | self.x1 <= other.x2 && self.x2 >= other.x1 && self.y1 <= other.y2 && self.y2 >= other.y1 70 | } 71 | 72 | pub fn center(&self) -> (i32, i32) { 73 | ((self.x1 + self.x2) / 2, (self.y1 + self.y2) / 2) 74 | } 75 | } 76 | 77 | pub struct Map { 78 | pub tiles: Vec, 79 | pub tile_entity: Vec>, 80 | pub tile_blocked: Vec, 81 | pub light_levels: Vec>, 82 | pub rooms: Vec, 83 | pub width: usize, 84 | pub height: usize, 85 | } 86 | 87 | impl Map { 88 | pub fn new(width: usize, height: usize) -> Self { 89 | Self { 90 | tiles: vec![], 91 | tile_entity: vec![Vec::new(); width * height], 92 | tile_blocked: vec![false; width * height], 93 | light_levels: vec![None; width * height], // initialize all tiles to none (unrevealed) 94 | rooms: vec![], 95 | width, 96 | height, 97 | } 98 | } 99 | 100 | pub fn generate_map_rooms_and_corridors(&mut self, room_count: usize, min_room_size: usize, max_room_size: usize) { 101 | self.tiles = vec![TileType::Wall; self.width * self.height]; 102 | let mut rng = rand::thread_rng(); 103 | 104 | for room_num in 0..room_count { 105 | let mut current_room: Room; // initialize room 106 | 107 | let mut attempt = 0; 108 | 'room_gen: loop { 109 | attempt += 1; 110 | // println!("generating new room #{}, attempt {}", room_num, attempt); 111 | let mut place_room = true; 112 | let room_w = rng.gen_range(min_room_size..max_room_size); 113 | let room_h = rng.gen_range(min_room_size..max_room_size); 114 | let room_x = rng.gen_range(1..self.width - room_w - 1) as i32; 115 | let room_y = rng.gen_range(1..self.height - room_h - 1) as i32; 116 | current_room = Room::new(room_x, room_y, room_w, room_h); 117 | 118 | // generate room dimensions 119 | // loop through other rooms and ensure they dont overlap 120 | for other_room in self.rooms.iter() { 121 | if current_room.overlaps_with(other_room) { 122 | println!("failed to generate room #{} on attempt {} with params: w:{}, h:{}, x:{}, y:{}", room_num + 1, attempt, room_w, room_h, room_x, room_y); 123 | place_room = false; 124 | } 125 | } 126 | 127 | // if the room didn't intersect with any other rooms, place it and break the generation loop 128 | if place_room { 129 | self.place_room(¤t_room); 130 | if !self.rooms.is_empty() { 131 | let (new_x, new_y) = current_room.center(); 132 | let (prev_x, prev_y) = self.rooms[self.rooms.len() - 1].center(); 133 | // place tunnel to last room, 50/50 if originating horizontally or vertically 134 | if rng.gen::() { 135 | self.place_tunnel_horizontal(prev_x, new_x, prev_y); 136 | self.place_tunnel_vertical(prev_y, new_y, new_x); 137 | } else { 138 | self.place_tunnel_vertical(prev_y, new_y, new_x); 139 | self.place_tunnel_horizontal(prev_x, new_x, prev_y); 140 | } 141 | } 142 | println!("succeeded in placing room #{} on attempt {} with params: w:{}, h:{}, x:{}, y:{}", room_num + 1, attempt, room_w, room_h, room_x, room_y); 143 | self.rooms.push(current_room); 144 | break 'room_gen; 145 | } 146 | } 147 | } 148 | } 149 | 150 | fn place_room(&mut self, room: &Room) { 151 | let mut pos: usize; 152 | for y in room.y1..room.y2 { 153 | for x in room.x1..room.x2 { 154 | pos = self.xy_idx(x, y); 155 | self.tiles[pos] = TileType::Floor; 156 | } 157 | } 158 | } 159 | 160 | fn place_tunnel_horizontal(&mut self, x1: i32, x2: i32, y: i32) { 161 | let mut pos: usize; 162 | for x in min(x1, x2)..=max(x1, x2) { 163 | pos = self.xy_idx(x, y); 164 | if pos > 0 && pos < self.width * self.height { 165 | self.tiles[pos] = TileType::Floor; 166 | } 167 | } 168 | } 169 | 170 | fn place_tunnel_vertical(&mut self, y1: i32, y2: i32, x: i32) { 171 | let mut pos: usize; 172 | for y in min(y1, y2)..=max(y1, y2) { 173 | pos = self.xy_idx(x, y); 174 | if pos > 0 && pos < self.width * self.height { 175 | self.tiles[pos] = TileType::Floor; 176 | } 177 | } 178 | } 179 | 180 | pub fn populate_blocked(&mut self) { 181 | for (i, tile) in self.tiles.iter_mut().enumerate() { 182 | self.tile_blocked[i] = tile.get_data().blocks_movement; 183 | } 184 | } 185 | 186 | pub fn render(&self, ctx: &mut BTerm) { 187 | let mut y = 0; 188 | let mut x = 0; 189 | // loops through tiles & keeps track of current iteration count in idx 190 | for (idx, _tile) in self.tiles.iter().enumerate() { 191 | // render tile if it has been initialized (revealed previously) 192 | if let Some(light_value) = self.light_levels[idx] { 193 | let tile_data = self.tiles[idx].get_data(); 194 | let fg = tile_data.base_fg.to_rgba(light_value); 195 | let bg = tile_data.base_bg.to_rgba(light_value); 196 | let glyph = tile_data.glyph; 197 | 198 | ctx.print_color(x, y, fg, bg, glyph); 199 | } 200 | 201 | // Move the coordinates 202 | x += 1; 203 | if x >= self.width as i32 { 204 | x = 0; 205 | y += 1; 206 | } 207 | } 208 | } 209 | 210 | // clears each tile, but doesn't free up memory and instead keeps memory allocated and ready for data. acquiring new memory is slow! 211 | pub fn clear_entity_content(&mut self) { 212 | for entity in self.tile_entity.iter_mut() { 213 | entity.clear(); 214 | } 215 | } 216 | 217 | // checks if an exit can be entered 218 | // fn is_exit_valid(&self, x: i32, y: i32) -> bool { 219 | // if x < 1 || x > self.width as i32 - 1 || y < 1 || y > self.height as i32 - 1 { return false } 220 | // let idx = self.xy_idx(x, y); 221 | // self.tiles[idx as usize] != TileType::Wall 222 | // } 223 | 224 | // returns index in map array from a coordinate (x, y) 225 | pub fn xy_idx(&self, x: i32, y: i32) -> usize { 226 | (y as usize * self.width) + x as usize 227 | } 228 | } 229 | --------------------------------------------------------------------------------