├── .gitignore ├── icon.png ├── Inconsolata-ExtraBold.ttf ├── screenshot └── screenshot.png ├── default_env.tres ├── demo.tscn ├── map_cell.tscn ├── Inconsolata-ExtraBold.ttf.import ├── icon.png.import ├── project.godot ├── export_presets.cfg ├── LICENSE ├── README.md ├── map_cell.gd ├── map.gd ├── demo.gd └── mrpas.gd /.gitignore: -------------------------------------------------------------------------------- 1 | .import 2 | export 3 | .godot 4 | -------------------------------------------------------------------------------- /icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matt-kimball/godot-mrpas/HEAD/icon.png -------------------------------------------------------------------------------- /Inconsolata-ExtraBold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matt-kimball/godot-mrpas/HEAD/Inconsolata-ExtraBold.ttf -------------------------------------------------------------------------------- /screenshot/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matt-kimball/godot-mrpas/HEAD/screenshot/screenshot.png -------------------------------------------------------------------------------- /default_env.tres: -------------------------------------------------------------------------------- 1 | [gd_resource type="Environment" load_steps=2 format=3 uid="uid://tsqnuky8g2gw"] 2 | 3 | [sub_resource type="Sky" id="1"] 4 | 5 | [resource] 6 | background_mode = 2 7 | sky = SubResource("1") 8 | -------------------------------------------------------------------------------- /demo.tscn: -------------------------------------------------------------------------------- 1 | [gd_scene load_steps=3 format=3 uid="uid://bmrc18pkb1pta"] 2 | 3 | [ext_resource type="Script" path="res://map.gd" id="1"] 4 | [ext_resource type="Script" path="res://demo.gd" id="2"] 5 | 6 | [node name="Demo" type="Node2D"] 7 | script = ExtResource("2") 8 | 9 | [node name="Map" type="Node2D" parent="."] 10 | script = ExtResource("1") 11 | 12 | [node name="PerformaceLabel" type="Label" parent="."] 13 | offset_right = 40.0 14 | offset_bottom = 23.0 15 | -------------------------------------------------------------------------------- /map_cell.tscn: -------------------------------------------------------------------------------- 1 | [gd_scene load_steps=3 format=3 uid="uid://dxbs8n6lxt7w1"] 2 | 3 | [ext_resource type="FontFile" uid="uid://cs43p5p7nm1pv" path="res://Inconsolata-ExtraBold.ttf" id="1"] 4 | [ext_resource type="Script" path="res://map_cell.gd" id="2"] 5 | 6 | [node name="MapCell" type="Node2D"] 7 | script = ExtResource("2") 8 | 9 | [node name="Background" type="ColorRect" parent="."] 10 | offset_right = 60.0 11 | offset_bottom = 90.0 12 | color = Color(0, 0.247059, 0.498039, 1) 13 | 14 | [node name="Glyph" type="Label" parent="."] 15 | offset_right = 60.0 16 | offset_bottom = 90.0 17 | theme_override_colors/font_color = Color(1, 1, 1, 1) 18 | theme_override_fonts/font = ExtResource("1") 19 | theme_override_font_sizes/font_size = 82 20 | text = "#" 21 | horizontal_alignment = 1 22 | vertical_alignment = 1 23 | -------------------------------------------------------------------------------- /Inconsolata-ExtraBold.ttf.import: -------------------------------------------------------------------------------- 1 | [remap] 2 | 3 | importer="font_data_dynamic" 4 | type="FontFile" 5 | uid="uid://cs43p5p7nm1pv" 6 | path="res://.godot/imported/Inconsolata-ExtraBold.ttf-31a712d615e22e5f7fa216ec92e0e38b.fontdata" 7 | 8 | [deps] 9 | 10 | source_file="res://Inconsolata-ExtraBold.ttf" 11 | dest_files=["res://.godot/imported/Inconsolata-ExtraBold.ttf-31a712d615e22e5f7fa216ec92e0e38b.fontdata"] 12 | 13 | [params] 14 | 15 | Rendering=null 16 | antialiasing=1 17 | generate_mipmaps=false 18 | multichannel_signed_distance_field=false 19 | msdf_pixel_range=8 20 | msdf_size=48 21 | allow_system_fallback=true 22 | force_autohinter=false 23 | hinting=1 24 | subpixel_positioning=1 25 | oversampling=0.0 26 | Fallbacks=null 27 | fallbacks=[] 28 | Compress=null 29 | compress=true 30 | preload=[] 31 | language_support={} 32 | script_support={} 33 | opentype_features={} 34 | -------------------------------------------------------------------------------- /icon.png.import: -------------------------------------------------------------------------------- 1 | [remap] 2 | 3 | importer="texture" 4 | type="CompressedTexture2D" 5 | uid="uid://d4bximirx4of3" 6 | path="res://.godot/imported/icon.png-487276ed1e3a0c39cad0279d744ee560.ctex" 7 | metadata={ 8 | "vram_texture": false 9 | } 10 | 11 | [deps] 12 | 13 | source_file="res://icon.png" 14 | dest_files=["res://.godot/imported/icon.png-487276ed1e3a0c39cad0279d744ee560.ctex"] 15 | 16 | [params] 17 | 18 | compress/mode=0 19 | compress/high_quality=false 20 | compress/lossy_quality=0.7 21 | compress/hdr_compression=1 22 | compress/normal_map=0 23 | compress/channel_pack=0 24 | mipmaps/generate=false 25 | mipmaps/limit=-1 26 | roughness/mode=0 27 | roughness/src_normal="" 28 | process/fix_alpha_border=true 29 | process/premult_alpha=false 30 | process/normal_map_invert_y=false 31 | process/hdr_as_srgb=false 32 | process/hdr_clamp_exposure=false 33 | process/size_limit=0 34 | detect_3d/compress_to=1 35 | -------------------------------------------------------------------------------- /project.godot: -------------------------------------------------------------------------------- 1 | ; Engine configuration file. 2 | ; It's best edited using the editor UI and not directly, 3 | ; since the parameters that go here are not all obvious. 4 | ; 5 | ; Format: 6 | ; [section] ; section goes between [] 7 | ; param=value ; assign values to parameters 8 | 9 | config_version=5 10 | 11 | [application] 12 | 13 | config/name="mrpas" 14 | run/main_scene="res://demo.tscn" 15 | config/features=PackedStringArray("4.0") 16 | config/icon="res://icon.png" 17 | 18 | [display] 19 | 20 | window/size/viewport_width=1920 21 | window/size/viewport_height=1080 22 | window/size/window_width_override=1280 23 | window/size/window_height_override=720 24 | window/stretch/mode="canvas_items" 25 | window/stretch/aspect="ignore" 26 | 27 | [physics] 28 | 29 | common/enable_pause_aware_picking=true 30 | 31 | [rendering] 32 | 33 | environment/defaults/default_environment="res://default_env.tres" 34 | quality/driver/driver_name="GLES2" 35 | vram_compression/import_etc=true 36 | -------------------------------------------------------------------------------- /export_presets.cfg: -------------------------------------------------------------------------------- 1 | [preset.0] 2 | 3 | name="HTML5" 4 | platform="HTML5" 5 | runnable=true 6 | custom_features="" 7 | export_filter="all_resources" 8 | include_filter="" 9 | exclude_filter="" 10 | export_path="export/index.html" 11 | script_export_mode=1 12 | script_encryption_key="" 13 | 14 | [preset.0.options] 15 | 16 | custom_template/debug="" 17 | custom_template/release="" 18 | variant/export_type=0 19 | vram_texture_compression/for_desktop=true 20 | vram_texture_compression/for_mobile=false 21 | html/export_icon=true 22 | html/custom_html_shell="" 23 | html/head_include="" 24 | html/canvas_resize_policy=2 25 | html/focus_canvas_on_start=true 26 | html/experimental_virtual_keyboard=false 27 | progressive_web_app/enabled=false 28 | progressive_web_app/offline_page="" 29 | progressive_web_app/display=1 30 | progressive_web_app/orientation=0 31 | progressive_web_app/icon_144x144="" 32 | progressive_web_app/icon_180x180="" 33 | progressive_web_app/icon_512x512="" 34 | progressive_web_app/background_color=Color( 0, 0, 0, 1 ) 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GDScript Mingos' Restrictive Precise Angle Shadowcasting 2 | Copyright 2022 Matt Kimball 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GDScript MRPAS 2 | 3 | Mingos' Restrictive Precise Angle Shadowcasting (MRPAS) is an algorithm 4 | used by traditional roguelike games for determining which map cells 5 | are in the player's field of view. This project implements that algorithm 6 | in GDScript (for use in the Godot game engine) and adds a demo project 7 | to show it in use. 8 | 9 | ![screenshot](screenshot/screenshot.png) 10 | 11 | For a description of the algorithm, see 12 | [the overview on RogueBasin](http://www.roguebasin.com/index.php?title=Restrictive_Precise_Angle_Shadowcasting). 13 | 14 | [A live web build of this project can be found on itch.io.](https://mkimball.itch.io/godot-mrpas-demo) 15 | 16 | # Usage 17 | 18 | To use this implementation, one can simply drop `mrpas.gd` into an existing 19 | Godot project. 20 | 21 | Expected usage is as follows: 22 | 23 | ``` 24 | # Create algorithm instance with a particular map size. 25 | var mrpas = MRPAS.new(map_size) 26 | 27 | # Mark some positions in the map as occluders. Here we do this once, 28 | # but it should be done for every non-transparent map cell. 29 | mrpas.set_transparent(occluder_position, false) 30 | 31 | # Mark all cells as non-visible. Necessary if the MRPAS object is 32 | # reused for multiple computations. 33 | mrpas.clear_field_of_view() 34 | 35 | # Compute which map cells are visible from the view position. 36 | mrpas.compute_field_of_view(view_position, max_view_distance) 37 | 38 | # Now that the field of view has been computed, we can check individual 39 | # map cells to see if they are visible from the view position. 40 | if mrpas.is_in_view(map_cell_position): 41 | ... # Perform some action here when the cell is in view. 42 | ``` 43 | 44 | # Acknowledgements 45 | 46 | The demo project includes 47 | [the Inconsolata font by Raph Levien](https://levien.com/type/myfonts/inconsolata.html). 48 | 49 | It is available under the SIL Open Font License. 50 | 51 | # License 52 | 53 | This implementation is available under the MIT License. 54 | -------------------------------------------------------------------------------- /map_cell.gd: -------------------------------------------------------------------------------- 1 | # Copyright 2022 Matt Kimball 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in all 11 | # copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | # SOFTWARE. 20 | 21 | 22 | @tool 23 | class_name MapCell 24 | extends Node2D 25 | 26 | 27 | # An individual cell on a map for the demo of Mingos' Restrictive Precise Angle 28 | # Shadowcasting. 29 | 30 | 31 | # The size in pixels of a map cell when drawn. 32 | const pixel_size = Vector2(60, 90) 33 | 34 | 35 | # The glyph to draw for a character occupying the cell. 36 | var character = null 37 | # The glyph to draw for the terrain of the cell. 38 | var terrain = '#' 39 | # True if the map cell is currently in the field of view. 40 | var in_view = true 41 | 42 | 43 | # Update the visual representation of the cell every frame. 44 | func _process(_delta) -> void: 45 | # Change colors if the map cell is in the field of view. 46 | if in_view: 47 | $Glyph.add_theme_color_override("font_color", Color("ffffff")) 48 | $Background.color = Color("003f7f") 49 | else: 50 | $Glyph.add_theme_color_override("font_color", Color("5f5f5f")) 51 | $Background.color = Color("000000") 52 | 53 | # If a character is in the cell, override the terrain glyph 54 | # using the character's glyph. 55 | if character: 56 | $Glyph.text = character 57 | $Glyph.add_theme_color_override("font_color", Color("ffff00")) 58 | else: 59 | $Glyph.text = terrain 60 | -------------------------------------------------------------------------------- /map.gd: -------------------------------------------------------------------------------- 1 | # Copyright 2022 Matt Kimball 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in all 11 | # copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | # SOFTWARE. 20 | 21 | 22 | @tool 23 | extends Node2D 24 | 25 | 26 | # A map for the demo of Mingos' Restrictive Precise Angle Shadowcasting. 27 | 28 | 29 | # The size of the map in map cells. 30 | # (If cells are displayed as 60 pixels x 90 pixels, then this size is correct 31 | # for 1080p resolution.) 32 | const size = Vector2(32, 12) 33 | # A map layout to generate cells from. 34 | const map_string = """ 35 | ################################ 36 | ######.........#................ 37 | ...............#................ 38 | ...............#......#.#.#.#.#. 39 | ...............#................ 40 | ......##############............ 41 | ..##################............ 42 | #............................... 43 | #............................... 44 | ............#.......#........... 45 | ............#................... 46 | ####........#................... 47 | """ 48 | 49 | 50 | # Map cell nodes arranged as an array of arrays. 51 | var _cells = [] 52 | 53 | 54 | # Generate and populate cells from the map_string layout. 55 | func _ready() -> void: 56 | _generate_cells() 57 | 58 | var map_lines = map_string.split("\n") 59 | for y in range(1, map_lines.size()): 60 | var line = map_lines[y] 61 | for x in range(line.length()): 62 | var cell = get_cell(Vector2(x, y - 1)) 63 | cell.terrain = line[x] 64 | 65 | 66 | # Return the map cell node for a particular map cell coordinate. 67 | # Returns null if the requested cell is outside the bounds of the map. 68 | func get_cell(map_position: Vector2) -> MapCell: 69 | if map_position.x < 0 or map_position.x >= size.x: 70 | return null 71 | if map_position.y < 0 or map_position.y >= size.y: 72 | return null 73 | 74 | return _cells[map_position.y as int][map_position.x as int] 75 | 76 | 77 | # Generate all cells by instancing the map cell scene. 78 | func _generate_cells() -> void: 79 | for y in range(size.y): 80 | var row = [] 81 | 82 | for x in range(size.x): 83 | var pixel_position = Vector2( 84 | MapCell.pixel_size.x * x, MapCell.pixel_size.y * y) 85 | 86 | var cell = preload("res://map_cell.tscn").instantiate() 87 | cell.transform = Transform2D().translated(pixel_position) 88 | add_child(cell) 89 | 90 | row.push_back(cell) 91 | 92 | _cells.push_back(row) 93 | -------------------------------------------------------------------------------- /demo.gd: -------------------------------------------------------------------------------- 1 | # Copyright 2022 Matt Kimball 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in all 11 | # copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | # SOFTWARE. 20 | 21 | 22 | @tool 23 | extends Node2D 24 | 25 | 26 | # A demo for Mingos' Restrictive Precise Angle Shadowcasting used for 27 | # visibility checks in a traditional roguelike. 28 | 29 | 30 | # The current position of the player in map cell coordinates. 31 | var _player_position: Vector2 = Vector2(0, 2) 32 | # The shadowcasting algorithm used for visibility checks. 33 | var _mrpas: MRPAS 34 | var _worst_fov_time 35 | 36 | 37 | # Place the player's symbol on the map and check initial visibility. 38 | func _ready() -> void: 39 | $Map.get_cell(_player_position).character = '@' 40 | _populate_mrpas() 41 | _compute_field_of_view() 42 | 43 | 44 | # Handle keypress events to move the player using arrow keys, WASD or 45 | # vi movement keys. 46 | func _input(event: InputEvent) -> void: 47 | if event is InputEventKey and event.pressed and not event.echo: 48 | var keycode = event.keycode 49 | 50 | if keycode == KEY_LEFT or keycode == KEY_H or keycode == KEY_A: 51 | _move_player(Vector2(-1, 0)) 52 | if keycode == KEY_RIGHT or keycode == KEY_L or keycode == KEY_D: 53 | _move_player(Vector2(1, 0)) 54 | if keycode == KEY_UP or keycode == KEY_K or keycode == KEY_W: 55 | _move_player(Vector2(0, -1)) 56 | if keycode == KEY_DOWN or keycode == KEY_J or keycode == KEY_S: 57 | _move_player(Vector2(0, 1)) 58 | 59 | 60 | # Move the player one space in a cardinal direction. 61 | func _move_player(direction: Vector2) -> void: 62 | var destination = _player_position + direction 63 | var destination_cell = $Map.get_cell(destination) 64 | 65 | # Don't allow movement outside the map. 66 | if not destination_cell: 67 | return 68 | 69 | # Don't allo movement into walls. 70 | if destination_cell.terrain == '#': 71 | return 72 | 73 | # Remove the player symbol from the previous cell and add it to the new one. 74 | var current_cell = $Map.get_cell(_player_position) 75 | current_cell.character = null 76 | _player_position = destination 77 | destination_cell.character = '@' 78 | 79 | var start_time = Time.get_ticks_usec() 80 | # Recompute visibility for the new position. 81 | _compute_field_of_view() 82 | var end_time = Time.get_ticks_usec() 83 | 84 | if OS.is_debug_build(): 85 | var fov_time = (end_time - start_time) / 1000.0 86 | if _worst_fov_time == null or fov_time > _worst_fov_time: 87 | _worst_fov_time = fov_time 88 | 89 | $PerformaceLabel.text = "compute fov: %0.3f ms / worst: %0.3f ms" % \ 90 | [fov_time, _worst_fov_time] 91 | 92 | 93 | # Populate the shadowcasting algorithm with transparent / occluded cells 94 | # using the position of walls on the map. 95 | func _populate_mrpas() -> void: 96 | _mrpas = MRPAS.new($Map.size) 97 | 98 | for y in range($Map.size.y): 99 | for x in range($Map.size.x): 100 | var map_position = Vector2(x, y) 101 | var cell = $Map.get_cell(map_position) 102 | 103 | # Specifically check for walls and assume all other cells are 104 | # transparent. 105 | _mrpas.set_transparent(map_position, cell.terrain != '#') 106 | 107 | 108 | # Recompute which map cells are visible. 109 | func _compute_field_of_view() -> void: 110 | # Mark all map cells as not in view. 111 | _mrpas.clear_field_of_view() 112 | 113 | # Use shadowcasting to find the cells which are visible from the 114 | # new payer position. 115 | _mrpas.compute_field_of_view( 116 | _player_position, max($Map.size.x, $Map.size.y) as int) 117 | 118 | for y in range($Map.size.y): 119 | for x in range($Map.size.x): 120 | var map_position = Vector2(x, y) 121 | 122 | # Mark the cell as visible if the shadowcasting has found it 123 | # to be in view. 124 | $Map.get_cell(map_position).in_view = _mrpas.is_in_view(map_position) 125 | -------------------------------------------------------------------------------- /mrpas.gd: -------------------------------------------------------------------------------- 1 | # Copyright 2022 Matt Kimball 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in all 11 | # copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | # SOFTWARE. 20 | 21 | 22 | class_name MRPAS 23 | extends RefCounted 24 | 25 | 26 | # An implementation of Mingos' Restrictive Precise Angle Shadowcasting. 27 | # 28 | # Intended for use in traditional roguelikes for determining which map cells 29 | # are visible from the player position on the map. 30 | # 31 | # For a description of the algorithm, see 32 | # http://www.roguebasin.com/index.php?title=Restrictive_Precise_Angle_Shadowcasting 33 | # 34 | # Expected usage is as follows: 35 | # 36 | # var mrpas = MRPAS.new(map_size) 37 | # mrpas.set_transparent(occluder_position, false) 38 | # 39 | # mrpas.clear_field_of_view() 40 | # mrpas.compute_field_of_view(view_position, max_view_distance) 41 | # 42 | # if mrpas.is_in_view(map_cell_position): 43 | # ... 44 | 45 | 46 | # When computing visibility for a quadrant, indicate which axis is major. 47 | enum _MajorAxis { X_AXIS, Y_AXIS } 48 | 49 | 50 | # The size of the map in cells. 51 | var _size: Vector2 52 | # A bool for each cell indicating whether it allows vision. 53 | var _transparent_cells: Array = [] 54 | # A bool for each cell indicating that it is currently in view. 55 | var _fov_cells: Array = [] 56 | 57 | 58 | # Initialize the algorithm for a map of a particular size. 59 | func _init(size: Vector2) -> void: 60 | _size = Vector2(size.x as int, size.y as int) 61 | 62 | # Build array-of-arrays for both transparency and field of view, 63 | # so that we can track each cell. 64 | for _y in range(_size.y): 65 | var transparent_row = [] 66 | var fov_row = [] 67 | 68 | for _x in range(_size.x): 69 | transparent_row.push_back(true) 70 | fov_row.push_back(true) 71 | 72 | _transparent_cells.push_back(transparent_row) 73 | _fov_cells.push_back(fov_row) 74 | 75 | 76 | # Returns true if a cell is marked as transparent. 77 | func is_transparent(position: Vector2) -> bool: 78 | if _in_bounds(position): 79 | return _transparent_cells[position.y][position.x] 80 | return false 81 | 82 | 83 | # Set the transparency of a cell in the map. 84 | func set_transparent(position: Vector2, transparent: bool) -> void: 85 | if _in_bounds(position): 86 | _transparent_cells[position.y][position.x] = transparent 87 | 88 | 89 | # Returns true if a cell is currently in view. 90 | func is_in_view(position: Vector2) -> bool: 91 | if _in_bounds(position): 92 | return _fov_cells[position.y][position.x] 93 | return false 94 | 95 | 96 | # Mark a map cell as in / not in the current view. 97 | func set_in_view(position: Vector2, in_view: bool) -> void: 98 | if _in_bounds(position): 99 | _fov_cells[position.y][position.x] = in_view 100 | 101 | 102 | # Mark all cells in the map as not in the current view. 103 | func clear_field_of_view() -> void: 104 | for y in range(_size.y): 105 | for x in range(_size.x): 106 | _fov_cells[y][x] = false 107 | 108 | 109 | # Compute the viewable cells from a particular view position by doing 110 | # each of the eight octants of the view. 111 | func compute_field_of_view(view_position: Vector2, max_distance: int) -> void: 112 | _compute_octant(_MajorAxis.Y_AXIS, -1, -1, view_position, max_distance) 113 | _compute_octant(_MajorAxis.Y_AXIS, -1, 1, view_position, max_distance) 114 | 115 | _compute_octant(_MajorAxis.Y_AXIS, 1, -1, view_position, max_distance) 116 | _compute_octant(_MajorAxis.Y_AXIS, 1, 1, view_position, max_distance) 117 | 118 | _compute_octant(_MajorAxis.X_AXIS, -1, -1, view_position, max_distance) 119 | _compute_octant(_MajorAxis.X_AXIS, -1, 1, view_position, max_distance) 120 | 121 | _compute_octant(_MajorAxis.X_AXIS, 1, -1, view_position, max_distance) 122 | _compute_octant(_MajorAxis.X_AXIS, 1, 1, view_position, max_distance) 123 | 124 | 125 | # Compute all visibile cells for one octant of the viewpoint. 126 | func _compute_octant( 127 | axis: int, 128 | major_sign: int, 129 | minor_sign: int, 130 | view_position: Vector2, 131 | max_distance: int) -> void: 132 | 133 | # Track occluders previously encountered in this octant. 134 | var occluders = [] 135 | var new_occluders = [] 136 | 137 | # Iterate along the major axis. 138 | for major in range(max_distance + 1): 139 | var any_transparent = false 140 | 141 | var position = view_position + _octant_to_offset( 142 | axis, major_sign * major, 0) 143 | if not _in_bounds(position): 144 | break 145 | 146 | var position_delta = _octant_to_offset(axis, 0, minor_sign) 147 | 148 | # Clamp the iteration range to the map bounds, which allows us to skip 149 | # bounds check in the inner loop. 150 | var clamped_minor = _clamp_to_map_bounds(position, position_delta, major + 1) 151 | 152 | var angle_half_step = 0.5 / (major + 1) as float 153 | 154 | # Iterate along the minor axis, but not beyond the major axis distance. 155 | for minor in range(clamped_minor): 156 | # It is important to recompute angle each iteration, because the 157 | # alternative approach of accumulating angle_half_step introduces 158 | # noticible artifacts due to accumulation of floating point 159 | # precision error. 160 | var angle = minor as float / (major + 1) as float 161 | 162 | var transparent = _is_transparent_no_bounds(position) 163 | 164 | # Check if occluders found on previous lines block this cell. 165 | if not _is_occluded(occluders, angle, angle_half_step, transparent): 166 | _set_in_view_no_bounds(position, true) 167 | if transparent: 168 | any_transparent = true 169 | else: 170 | # The occluder represents a range of angle values, rather 171 | # than a coordinate. 172 | var occluder = Vector2(angle, angle + 2.0 * angle_half_step) 173 | new_occluders.push_back(occluder) 174 | 175 | position += position_delta 176 | 177 | # If no tranparent cells were seen on this line, we can stop. 178 | if not any_transparent: 179 | break 180 | 181 | # Add any occluders we encountered on this line for checking 182 | # future lines. 183 | occluders = occluders + new_occluders 184 | new_occluders.clear() 185 | 186 | 187 | # Clamp the range of iteration to the bounds of the map. This returns a 188 | # new maximum number of iterations, such that the iteration is entirely 189 | # within the boundary of the map. 190 | func _clamp_to_map_bounds( 191 | position: Vector2, 192 | position_delta: Vector2, 193 | iterations: int) -> int: 194 | 195 | if position.x + position_delta.x * iterations < 0: 196 | iterations = int(-position.x / position_delta.x) + 1 197 | if position.x + position_delta.x * iterations > _size.x: 198 | iterations = int((_size.x - position.x) / position_delta.x) 199 | 200 | if position.y + position_delta.y * iterations < 0: 201 | iterations = int(-position.y / position_delta.y) + 1 202 | if position.y + position_delta.y * iterations > _size.y: 203 | iterations = int((_size.y - position.y) / position_delta.y) 204 | 205 | return iterations 206 | 207 | 208 | # Returns true if a cell within the quadrant should not be considered within 209 | # the view. 210 | # 211 | # For cells which are themselves transparent, require visibility to the mid 212 | # point as well as either of the sides of the cell. 213 | # 214 | # For cells which are not transparent, require only visiblity to one of the 215 | # three tested points. 216 | func _is_occluded( 217 | occluders: Array, 218 | angle: float, 219 | angle_half_step: float, 220 | transparent: bool) -> bool: 221 | 222 | var begin = _is_angle_occluded(occluders, angle) 223 | var mid = _is_angle_occluded(occluders, angle + angle_half_step) 224 | var end = _is_angle_occluded(occluders, angle + 2.0 * angle_half_step) 225 | 226 | if not transparent and (not begin or not mid or not end): 227 | return false 228 | 229 | if transparent and not mid and (not begin or not end): 230 | return false 231 | 232 | return true 233 | 234 | 235 | # Given a list of occluded angle ranges and an angle test test, return 236 | # true if the angle tested is occluded by at least one of the occluders. 237 | func _is_angle_occluded(occluders: Array, angle: float) -> bool: 238 | for occluder in occluders: 239 | if angle >= occluder.x and angle <= occluder.y: 240 | return true 241 | 242 | return false 243 | 244 | 245 | # Given a major axis, and offsets along the major and minor axes, return 246 | # an equivalent (x, y) coordinate. 247 | func _octant_to_offset(axis: int, major: int, minor: int) -> Vector2: 248 | if axis == _MajorAxis.Y_AXIS: 249 | return Vector2(minor, major) 250 | else: 251 | return Vector2(major, minor) 252 | 253 | 254 | # Test whether a particular position is within the map bounds. 255 | func _in_bounds(position: Vector2) -> bool: 256 | var x_in_bounds = position.x >= 0 and position.x < _size.x 257 | var y_in_bounds = position.y >= 0 and position.y < _size.y 258 | return x_in_bounds and y_in_bounds 259 | 260 | 261 | # is_transparent, but without a bounds check for use in the inner loop of 262 | # visibility computation. 263 | func _is_transparent_no_bounds(position: Vector2) -> bool: 264 | return _transparent_cells[position.y][position.x] 265 | 266 | 267 | # set_in_view, but with no bounds check. For use in the inner loop of 268 | # visibility computation. 269 | func _set_in_view_no_bounds(position: Vector2, in_view: bool) -> void: 270 | _fov_cells[position.y][position.x] = in_view 271 | --------------------------------------------------------------------------------