├── world ├── __init__.py ├── semirandom.py ├── tiles.py └── world.py ├── font.otf ├── icon.png ├── requirements.txt ├── screenshots ├── img1.png ├── img2.png ├── img3.png └── img4.png ├── LICENSE ├── README.md ├── .gitignore └── game.py /world/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /font.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SudoOmbro/OmbroBox/HEAD/font.otf -------------------------------------------------------------------------------- /icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SudoOmbro/OmbroBox/HEAD/icon.png -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SudoOmbro/OmbroBox/HEAD/requirements.txt -------------------------------------------------------------------------------- /screenshots/img1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SudoOmbro/OmbroBox/HEAD/screenshots/img1.png -------------------------------------------------------------------------------- /screenshots/img2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SudoOmbro/OmbroBox/HEAD/screenshots/img2.png -------------------------------------------------------------------------------- /screenshots/img3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SudoOmbro/OmbroBox/HEAD/screenshots/img3.png -------------------------------------------------------------------------------- /screenshots/img4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SudoOmbro/OmbroBox/HEAD/screenshots/img4.png -------------------------------------------------------------------------------- /world/semirandom.py: -------------------------------------------------------------------------------- 1 | import random 2 | from time import time 3 | 4 | _NUMBERS = [*range(1024)] 5 | random.shuffle(_NUMBERS) 6 | _NUMBERS = tuple(_NUMBERS) 7 | 8 | _LAST_PLACE = len(_NUMBERS) - 1 9 | _CURSOR: int = -1 10 | 11 | 12 | def randint(max_num: int) -> int: 13 | """ 14 | Implements the DOOM way of getting random numbers, faster than the base random by about 3.5 times. 15 | 16 | :return: a random integer between 0 and max_num 17 | """ 18 | global _CURSOR 19 | if _CURSOR == _LAST_PLACE: 20 | _CURSOR = 0 21 | else: 22 | _CURSOR += 1 23 | return _NUMBERS[_CURSOR] % max_num 24 | 25 | 26 | if __name__ == "__main__": 27 | start_time = time() 28 | for _ in range(1000000): 29 | random.randint(0, 10) 30 | random_time = time() - start_time 31 | start_time = time() 32 | for _ in range(1000000): 33 | randint(10) 34 | semirandom_time = time() - start_time 35 | print(f"Random time: {random_time}") 36 | print(f"Semi-Random time: {semirandom_time}") 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Matteo Ferrari 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # OmbroBox 2 | A simple physics sandbox. 3 | 4 |
5 | 6 | 7 | 8 | 9 |
10 | 11 | ## Features 12 | 13 | - ECS-like architecture achieved through multiple inheritace 14 | - Sand, Water & Gas Physics 15 | - Buoyancy 16 | - Heath transfer 17 | - Status changes (ice -> water -> vapor) 18 | - Scriptable custom tile behaviours 19 | 20 | ## controls 21 | - Click with the `Left mouse button` to add the selected Tile 22 | - Click with the `Right mouse button` to delete the tile you are hovering on 23 | - Use the `Mouse wheel` to select different tiles 24 | - Press `Space` to Pause/Unpause the simulation 25 | - Press `F1` to enable additional information 26 | - Press `ESC` to reset the world 27 | - Press `Left CTRL` while adding or deleting tiles to enable big brush mode 28 | 29 | ## Performance 30 | For being pure python it's as good as it gets (without using multiprocessing or Cython), i would suggest using PyPy. 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | -------------------------------------------------------------------------------- /game.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from typing import List, Tuple 3 | 4 | import pygame 5 | from pygame.locals import * 6 | 7 | from world.world import World, Dir 8 | from world.tiles import TILES 9 | 10 | """ 11 | All PyGame stuff is here (rendering & inputs) 12 | """ 13 | 14 | pygame.init() 15 | 16 | FONT = pygame.font.Font('font.otf', 18) 17 | SMALL_FONT = pygame.font.Font('font.otf', 14) 18 | 19 | # Game Setup 20 | FPS = 60 21 | fpsClock = pygame.time.Clock() 22 | WINDOW = pygame.display.set_mode((1280, 720), pygame.RESIZABLE) 23 | pygame.display.set_caption('OmbroBox') 24 | pygame.display.set_icon(pygame.image.load("icon.png")) 25 | 26 | paused_text = FONT.render("Simulation paused", False, (255, 255, 255)) 27 | 28 | 29 | def render(world: World, selected_tile: int, mouse_position: Tuple[int, int], paused: bool, tiles_info: bool): 30 | # set window caption (show FPS) 31 | pygame.display.set_caption(f'OmbroBox | FPS: {int(fpsClock.get_fps())}') 32 | # render world 33 | surface = pygame.Surface((world.width, world.height)) 34 | for tile in world.tiles: 35 | surface.set_at((tile.x, tile.y), tile.color) 36 | surface.set_at(mouse_position, (255, 255, 255)) 37 | scaled_surface = pygame.transform.scale(surface, WINDOW.get_size()) 38 | # render selected tile 39 | tile_text = FONT.render( 40 | f"selected ({selected_tile + 1}/{len(TILES)}): {TILES[selected_tile].NAME}", 41 | False, 42 | (255, 255, 255) 43 | ) 44 | scaled_surface.blit(tile_text, (10, 10)) 45 | # render additional information if tiles info is on 46 | if tiles_info: 47 | total_particles_text = FONT.render(f"Total tiles: {len(world.tiles)}", False, (255, 255, 255)) 48 | scaled_surface.blit(total_particles_text, (10, 50)) 49 | tile = world.spatial_matrix[mouse_position[1]][mouse_position[0]] 50 | if tile: 51 | mouse_pos = pygame.mouse.get_pos() 52 | tile_type_text = SMALL_FONT.render( 53 | f"Type: {tile.NAME}", 54 | False, 55 | (255, 255, 255) 56 | ) 57 | tile_type_text_shadow = SMALL_FONT.render( 58 | f"Type: {tile.NAME}", 59 | False, 60 | (0, 0, 0) 61 | ) 62 | scaled_surface.blit(tile_type_text_shadow, (mouse_pos[0] + 12, mouse_pos[1] + 2)) 63 | scaled_surface.blit(tile_type_text, (mouse_pos[0] + 10, mouse_pos[1])) 64 | if "heat" in tile.__dict__: 65 | tile_heat_text = SMALL_FONT.render( 66 | f"Heat: {tile.heat}", 67 | False, 68 | (255, 255, 255) 69 | ) 70 | tile_heat_text_shadow = SMALL_FONT.render( 71 | f"Heat: {tile.heat}", 72 | False, 73 | (0, 0, 0) 74 | ) 75 | scaled_surface.blit(tile_heat_text_shadow, (mouse_pos[0] + 12, mouse_pos[1] + 22)) 76 | scaled_surface.blit(tile_heat_text, (mouse_pos[0] + 10, mouse_pos[1] + 20)) 77 | # render pause text if the simulation is paused 78 | if paused: 79 | scaled_surface.blit(paused_text, (WINDOW.get_width() - paused_text.get_width() - 10, 10)) 80 | # render surface to window 81 | WINDOW.blit(scaled_surface, (0, 0)) 82 | pygame.display.flip() 83 | 84 | 85 | def clamp(n, smallest, largest) -> int: 86 | ll: List[int] = [smallest, n, largest] 87 | ll.sort() 88 | return ll[1] 89 | 90 | 91 | def get_mouse_world_position(world: World) -> Tuple[int, int]: 92 | window_size = WINDOW.get_size() 93 | mouse_pos = pygame.mouse.get_pos() 94 | mouse_x = clamp(int((mouse_pos[0] / window_size[0]) * world.width), 0, world.width - 1) 95 | mouse_y = clamp(int((mouse_pos[1] / window_size[1]) * world.height), 0, world.height - 1) 96 | return mouse_x, mouse_y 97 | 98 | 99 | def main(): 100 | world = World(160, 90) 101 | selected_tile: int = 0 102 | pause: bool = False 103 | tiles_info: bool = False 104 | 105 | while True: 106 | # Get mouse position 107 | mouse_position = get_mouse_world_position(world) 108 | # Get inputs 109 | for event in pygame.event.get(): 110 | if event.type == QUIT: 111 | pygame.quit() 112 | sys.exit() 113 | if event.type == MOUSEWHEEL: 114 | if event.y == -1: 115 | if selected_tile == 0: 116 | selected_tile = len(TILES) - 1 117 | else: 118 | selected_tile -= 1 119 | else: 120 | if selected_tile == len(TILES) - 1: 121 | selected_tile = 0 122 | else: 123 | selected_tile += 1 124 | if event.type == KEYDOWN: 125 | if event.unicode == " ": 126 | pause = not pause 127 | elif event.scancode == 58: 128 | # Press F1 129 | tiles_info = not tiles_info 130 | elif event.scancode == 41: 131 | # Press ESC 132 | world = World(160, 90) 133 | if pygame.mouse.get_pressed()[0]: 134 | world.add_tile(TILES[selected_tile], mouse_position[0], mouse_position[1]) 135 | if pygame.key.get_pressed()[K_LCTRL]: 136 | for direction in Dir.ALL: 137 | world.add_tile( 138 | TILES[selected_tile], 139 | mouse_position[0] + direction[0], 140 | mouse_position[1] + direction[1] 141 | ) 142 | elif pygame.mouse.get_pressed()[2]: 143 | world.delete_tile(mouse_position[0], mouse_position[1]) 144 | if pygame.key.get_pressed()[K_LCTRL]: 145 | for direction in Dir.ALL: 146 | world.delete_tile( 147 | mouse_position[0] + direction[0], 148 | mouse_position[1] + direction[1] 149 | ) 150 | # update physics 151 | if not pause: 152 | world.update() 153 | # render 154 | render(world, selected_tile, mouse_position, pause, tiles_info) 155 | fpsClock.tick(FPS) 156 | 157 | 158 | if __name__ == '__main__': 159 | main() 160 | -------------------------------------------------------------------------------- /world/tiles.py: -------------------------------------------------------------------------------- 1 | from typing import List, Type 2 | 3 | from world.world import Tile, GasTile, World, LiquidTile, SemiSolidTile, SolidTile, CustomTile, Dir, HeatTile 4 | from world.semirandom import randint 5 | 6 | TILES: List[Type[Tile]] = [] 7 | _TILES_TO_FIX: List[Type[Tile]] = [] 8 | 9 | 10 | def add_to_tile_list(tile: Type[Tile]) -> Type[Tile]: 11 | TILES.append(tile) 12 | print(f"Added tile {tile.NAME}") 13 | return tile 14 | 15 | 16 | # Solid tiles ---------------------- 17 | 18 | 19 | @add_to_tile_list 20 | class ConcreteTile(SolidTile): 21 | 22 | NAME = "Concrete" 23 | 24 | def __init__(self, world: World, x: int, y: int): 25 | super().__init__( 26 | (140 + randint(40), 140 + randint(40), 140 + randint(40)), 27 | 100000, 28 | world, 29 | x, 30 | y 31 | ) 32 | 33 | 34 | @add_to_tile_list 35 | class StrangeMatterTile(SolidTile): 36 | 37 | NAME = "Strange Matter" 38 | 39 | def __init__(self, world: World, x: int, y: int): 40 | super().__init__( 41 | (10 + randint(245), 10 + randint(245), 10 + randint(245)), 42 | 10000000, 43 | world, 44 | x, 45 | y, 46 | heat_transfer_coefficient=0 47 | ) 48 | 49 | 50 | @add_to_tile_list 51 | class WoodTile(SolidTile): 52 | 53 | NAME = "Wood" 54 | UPPER_HEATH_THRESHOLD = 500, "BurningWood" 55 | 56 | def __init__(self, world: World, x: int, y: int): 57 | super().__init__( 58 | (117 + randint(40), 63 + randint(40), 4 + randint(40)), 59 | 10000, 60 | world, 61 | x, 62 | y, 63 | heat_transfer_coefficient=0.01 64 | ) 65 | 66 | 67 | @add_to_tile_list 68 | class BurningWood(SolidTile): 69 | 70 | NAME = "Burning Wood" 71 | UPPER_HEATH_THRESHOLD = 2000, "AshTile" 72 | LOWER_HEATH_THRESHOLD = 90, WoodTile 73 | 74 | def __init__(self, world: World, x: int, y: int): 75 | super().__init__( 76 | (209 + randint(40), 118 + randint(40), 4), 77 | 100000, 78 | world, 79 | x, 80 | y, 81 | base_heat=500, 82 | heat_transfer_coefficient=1, 83 | passive_heat_loss=-5 84 | ) 85 | 86 | 87 | @add_to_tile_list 88 | class GlassTile(SolidTile): 89 | 90 | NAME = "Glass" 91 | 92 | def __init__(self, world: World, x: int, y: int): 93 | super().__init__( 94 | (152 + randint(40), 203 + randint(40), 206 + randint(40)), 95 | 100000, 96 | world, 97 | x, 98 | y, 99 | heat_transfer_coefficient=0.5 100 | ) 101 | 102 | 103 | # Semi solid tiles -------------------- 104 | 105 | 106 | @add_to_tile_list 107 | class SandTile(SemiSolidTile): 108 | 109 | NAME = "Sand" 110 | UPPER_HEATH_THRESHOLD = 800, GlassTile 111 | 112 | def __init__(self, world: World, x: int, y: int): 113 | super().__init__( 114 | (205-randint(50), 205-randint(50), 0), 115 | 10, 116 | world, 117 | x, 118 | y, 119 | heat_transfer_coefficient=0.05 120 | ) 121 | 122 | 123 | @add_to_tile_list 124 | class RockTile(SemiSolidTile): 125 | 126 | NAME = "Rock" 127 | UPPER_HEATH_THRESHOLD = 1000, "LavaTile" 128 | 129 | def __init__(self, world: World, x: int, y: int): 130 | super().__init__( 131 | (40-randint(10), 40-randint(10), 50-randint(10)), 132 | 800, 133 | world, 134 | x, 135 | y 136 | ) 137 | 138 | 139 | @add_to_tile_list 140 | class IceTile(SemiSolidTile): 141 | 142 | NAME = "Ice" 143 | UPPER_HEATH_THRESHOLD = 10, "WaterTile" 144 | 145 | def __init__(self, world: World, x: int, y: int): 146 | super().__init__( 147 | (200-randint(20), 200-randint(20), 255-randint(20)), 148 | 1, 149 | world, 150 | x, 151 | y, 152 | base_heat=-40, 153 | ) 154 | 155 | 156 | @add_to_tile_list 157 | class AshTile(SemiSolidTile): 158 | 159 | NAME = "Ash" 160 | 161 | def __init__(self, world: World, x: int, y: int): 162 | super().__init__( 163 | (140-randint(20), 140-randint(20), 140-randint(20)), 164 | 1, 165 | world, 166 | x, 167 | y, 168 | base_heat=100, 169 | ) 170 | 171 | 172 | @add_to_tile_list 173 | class GunpowderTile(SemiSolidTile): 174 | 175 | NAME = "Gun powder" 176 | UPPER_HEATH_THRESHOLD = 500, "ExplosionTile" 177 | 178 | def __init__(self, world: World, x: int, y: int): 179 | super().__init__( 180 | (40-randint(20), 40-randint(20), 40-randint(20)), 181 | 4, 182 | world, 183 | x, 184 | y 185 | ) 186 | 187 | 188 | # Liquid tiles ------------------- 189 | 190 | @add_to_tile_list 191 | class WaterTile(LiquidTile): 192 | 193 | NAME = "Water" 194 | UPPER_HEATH_THRESHOLD = 100, "VaporTile" 195 | LOWER_HEATH_THRESHOLD = 0, IceTile 196 | 197 | def __init__(self, world: World, x: int, y: int): 198 | super().__init__( 199 | (0, 0, 155+randint(100)), 200 | 2, 201 | world, 202 | x, 203 | y, 204 | ) 205 | 206 | 207 | @add_to_tile_list 208 | class OilTile(LiquidTile): 209 | 210 | NAME = "Oil" 211 | UPPER_HEATH_THRESHOLD = 300, "FireTile" 212 | 213 | def __init__(self, world: World, x: int, y: int): 214 | super().__init__( 215 | (193-randint(20), 193-randint(20), 69-randint(10)), 216 | 1, 217 | world, 218 | x, 219 | y, 220 | base_heat=25, 221 | ) 222 | 223 | 224 | @add_to_tile_list 225 | class LavaTile(LiquidTile): 226 | 227 | NAME = "Lava" 228 | LOWER_HEATH_THRESHOLD = 500, RockTile 229 | 230 | def __init__(self, world: World, x: int, y: int): 231 | super().__init__( 232 | (255 - randint(20), 0, 0), 233 | 1000, 234 | world, 235 | x, 236 | y, 237 | base_heat=10000, 238 | heat_transfer_coefficient=0.1 239 | ) 240 | 241 | 242 | @add_to_tile_list 243 | class LiquidNitrogen(LiquidTile): 244 | 245 | NAME = "Liquid Nitrogen" 246 | UPPER_HEATH_THRESHOLD = 0, None 247 | 248 | def __init__(self, world: World, x: int, y: int): 249 | super().__init__( 250 | (255, 255, 255), 251 | 0, 252 | world, 253 | x, 254 | y, 255 | base_heat=-10000, 256 | ) 257 | 258 | 259 | # Gas tiles ----------------- 260 | 261 | @add_to_tile_list 262 | class VaporTile(GasTile): 263 | 264 | NAME = "Vapor" 265 | LOWER_HEATH_THRESHOLD = 60, WaterTile 266 | 267 | def __init__(self, world: World, x: int, y: int): 268 | super().__init__( 269 | (255-randint(20), 255-randint(20), 255-randint(20)), 270 | 0, 271 | world, 272 | x, 273 | y, 274 | base_heat=220 + randint(120), 275 | passive_heat_loss=1 276 | ) 277 | 278 | 279 | @add_to_tile_list 280 | class SmokeTile(GasTile): 281 | 282 | NAME = "Smoke" 283 | LOWER_HEATH_THRESHOLD = 100, None 284 | 285 | def __init__(self, world: World, x: int, y: int): 286 | super().__init__( 287 | (50-randint(20), 50-randint(20), 50-randint(20)), 288 | 0, 289 | world, 290 | x, 291 | y, 292 | base_heat=300 + randint(120), 293 | passive_heat_loss=1 294 | ) 295 | 296 | 297 | # custom tiles -------------------------------- 298 | 299 | @add_to_tile_list 300 | class FireTile(CustomTile): 301 | 302 | NAME = "Fire" 303 | 304 | DIRECTIONS = ( 305 | (Dir.UP, Dir.UP_LEFT, Dir.UP_RIGHT), 306 | (Dir.UP_LEFT, Dir.UP, Dir.UP_RIGHT), 307 | (Dir.UP_RIGHT, Dir.UP_LEFT, Dir.UP), 308 | (Dir.LEFT, Dir.RIGHT, Dir.UP_LEFT, Dir.UP_RIGHT), 309 | (Dir.RIGHT, Dir.LEFT, Dir.UP_RIGHT, Dir.UP_LEFT), 310 | (Dir.UP_LEFT, Dir.UP_RIGHT, Dir.LEFT, Dir.RIGHT), 311 | (Dir.UP_RIGHT, Dir.UP_LEFT, Dir.RIGHT, Dir.LEFT) 312 | ) 313 | 314 | def __init__(self, world: World, x: int, y: int): 315 | super().__init__( 316 | (242-randint(20), 141-randint(20), 0), 317 | -2, 318 | world, 319 | x, 320 | y 321 | ) 322 | self.duration: int = 180 + randint(180) 323 | 324 | def custom_update(self): 325 | for direction in self.DIRECTIONS[randint(7)]: 326 | next_pos = self.get_next_pos(direction) 327 | if not next_pos.valid: 328 | continue 329 | checked_tile: Tile = self.world.spatial_matrix[next_pos.y][next_pos.x] 330 | if not checked_tile: 331 | self.world.spatial_matrix[self.y][self.x] = None 332 | self.x = next_pos.x 333 | self.y = next_pos.y 334 | self.world.spatial_matrix[self.y][self.x] = self 335 | break 336 | elif checked_tile in self.world.heat_tiles: 337 | checked_tile.heat += 100 338 | self.duration -= 50 339 | break 340 | self.duration -= 1 341 | if self.duration <= 0: 342 | self.remove() 343 | 344 | 345 | @add_to_tile_list 346 | class GreyGooTile(CustomTile): 347 | 348 | NAME = "Grey Goo" 349 | 350 | def __init__(self, world: World, x: int, y: int): 351 | super().__init__( 352 | (180, 180, 180), 353 | 0, 354 | world, 355 | x, 356 | y 357 | ) 358 | 359 | def custom_update(self): 360 | for direction in Dir.ALL: 361 | tile: Tile = self.get_neighbour_tile(direction) 362 | if tile and (type(tile) != GreyGooTile): 363 | tile.transform(GreyGooTile) 364 | 365 | 366 | @add_to_tile_list 367 | class AcidTile(LiquidTile, CustomTile): 368 | 369 | NAME = "Acid" 370 | 371 | def __init__(self, world: World, x: int, y: int): 372 | super().__init__( 373 | (0, 235 + randint(20), 0), 374 | 0, 375 | world, 376 | x, 377 | y 378 | ) 379 | 380 | def custom_update(self): 381 | if randint(20) != 0: 382 | return 383 | for direction in Dir.ALL: 384 | tile: Tile = self.get_neighbour_tile(direction) 385 | if tile and (type(tile) != AcidTile): 386 | tile.remove() 387 | self.remove() 388 | return 389 | 390 | 391 | @add_to_tile_list 392 | class ExplosionTile(HeatTile, CustomTile): 393 | 394 | NAME = "Explosion" 395 | 396 | def __init__(self, world: World, x: int, y: int): 397 | super().__init__( 398 | (255, 255, 0), 399 | 10000, 400 | world, 401 | x, 402 | y, 403 | base_heat=2000 404 | ) 405 | self.range: int = 10 406 | self.tile_duration: int = 2 407 | 408 | def custom_update(self): 409 | if self.tile_duration == 0: 410 | if self.range != 0: 411 | new_range = self.range - 1 412 | for direction in (Dir.UP, Dir.LEFT, Dir.RIGHT, Dir.DOWN): 413 | next_pos = self.get_next_pos(direction) 414 | if not next_pos.valid: 415 | continue 416 | checked_tile: Tile = self.world.spatial_matrix[next_pos.y][next_pos.x] 417 | if checked_tile and (type(checked_tile) != ExplosionTile): 418 | checked_tile.remove() 419 | new_tile = self.world.add_tile(ExplosionTile, next_pos.x, next_pos.y) 420 | new_tile.range = new_range 421 | else: 422 | new_tile = SmokeTile(self.world, self.x, self.y) 423 | self.world.tiles_to_add.append(new_tile) 424 | self.remove() 425 | else: 426 | self.tile_duration -= 1 427 | 428 | def update_temperature(self): 429 | self.do_exchange_heat() 430 | 431 | 432 | # This is needed as a workaround of Python's lack of forward declaration 433 | for tile_to_fix in TILES: 434 | if "UPPER_HEATH_THRESHOLD" in tile_to_fix.__dict__: 435 | if tile_to_fix.UPPER_HEATH_THRESHOLD: 436 | if type(tile_to_fix.UPPER_HEATH_THRESHOLD[1]) == str: 437 | print(f"fixing {tile_to_fix.UPPER_HEATH_THRESHOLD[1]}") 438 | tile_to_fix.UPPER_HEATH_THRESHOLD = \ 439 | tile_to_fix.UPPER_HEATH_THRESHOLD[0], \ 440 | globals()[tile_to_fix.UPPER_HEATH_THRESHOLD[1]] 441 | if tile_to_fix.LOWER_HEATH_THRESHOLD: 442 | if type(tile_to_fix.LOWER_HEATH_THRESHOLD[1]) == str: 443 | print(f"fixing {tile_to_fix.LOWER_HEATH_THRESHOLD[1]}") 444 | tile_to_fix.LOWER_HEATH_THRESHOLD = \ 445 | tile_to_fix.LOWER_HEATH_THRESHOLD[0], \ 446 | globals()[tile_to_fix.LOWER_HEATH_THRESHOLD[1]] 447 | -------------------------------------------------------------------------------- /world/world.py: -------------------------------------------------------------------------------- 1 | from functools import cache 2 | from typing import Tuple, List, Type, Iterable, Callable 3 | 4 | from world.semirandom import randint 5 | 6 | 7 | class Dir: 8 | """ Defines all the possible directions """ 9 | 10 | UP = 0, -1 11 | DOWN = 0, 1, 12 | LEFT = -1, 0, 13 | RIGHT = 1, 0 14 | UP_LEFT = -1, -1 15 | UP_RIGHT = 1, -1 16 | DOWN_LEFT = -1, 1 17 | DOWN_RIGHT = 1, 1 18 | 19 | ALL = ( 20 | DOWN, 21 | DOWN_LEFT, 22 | DOWN_RIGHT, 23 | LEFT, 24 | UP_LEFT, 25 | UP, 26 | UP_RIGHT, 27 | RIGHT, 28 | ) 29 | 30 | 31 | class TileFlags: 32 | CAN_MOVE = 0 33 | TRANSMITS_HEAT = 1 34 | 35 | 36 | class NextPosition: 37 | 38 | def __init__(self, x: int, y: int, valid: bool): 39 | self.x = x 40 | self.y = y 41 | self.valid = valid 42 | 43 | 44 | class Tile: 45 | 46 | NAME: str 47 | 48 | def __init__( 49 | self, 50 | color: Tuple[int, int, int], 51 | density: int, 52 | world: "World", 53 | x: int, 54 | y: int 55 | ): 56 | # render stuff 57 | self.color = color 58 | # Physics stuff 59 | self.density = density 60 | # position 61 | self.x = x 62 | self.y = y 63 | self.world = world 64 | # control flags 65 | self.active: bool = True 66 | self.last_update: int = 0 67 | 68 | def remove(self): 69 | if self.active: 70 | self.world.tiles_to_delete.append(self) 71 | self.active = False 72 | return True 73 | return False 74 | 75 | def add(self): 76 | self.world.tiles.append(self) 77 | self.world.spatial_matrix[self.y][self.x] = self 78 | 79 | def delete(self): 80 | self.world.tiles.remove(self) 81 | self.world.spatial_matrix[self.y][self.x] = None 82 | 83 | def get_next_pos(self, relative_vector: Tuple[int, int]) -> NextPosition: 84 | # returns the world position given a vector relative to the tile 85 | next_x: int = self.x + relative_vector[0] 86 | if not 0 <= next_x < self.world.width: 87 | return NextPosition(0, 0, False) 88 | next_y: int = self.y + relative_vector[1] 89 | if not 0 <= next_y < self.world.height: 90 | return NextPosition(0, 0, False) 91 | return NextPosition(next_x, next_y, True) 92 | 93 | def get_neighbour_tile(self, direction: Tuple[int, int]) -> "Tile" or None: 94 | next_pos = self.get_next_pos(direction) 95 | if not next_pos.valid: 96 | return None 97 | checked_tile = self.world.spatial_matrix[next_pos.y][next_pos.x] 98 | if not checked_tile: 99 | return None 100 | return checked_tile 101 | 102 | def transform(self, new_type: type) -> "Tile" or None: 103 | if self.remove(): 104 | new_tile = new_type(self.world, self.x, self.y) 105 | self.world.tiles_to_add.append(new_tile) 106 | return new_tile 107 | return None 108 | 109 | 110 | class MovingTile(Tile): 111 | 112 | _MAX_UPDATE_SKIP = 3 113 | 114 | def __init__(self, color: Tuple[int, int, int], density: int, world: "World", x: int, y: int): 115 | super().__init__(color, density, world, x, y) 116 | self._skip_update: int = 0 117 | self._cooldown: int = 0 118 | 119 | def add(self): 120 | super().add() 121 | self.world.moving_tiles.append(self) 122 | 123 | def delete(self): 124 | super().delete() 125 | self.world.moving_tiles.remove(self) 126 | 127 | def move(self, new_x: int, new_y: int, replacement_tile: "Tile" or None): 128 | self.world.spatial_matrix[self.y][self.x] = replacement_tile 129 | self.x = new_x 130 | self.y = new_y 131 | self.world.spatial_matrix[self.y][self.x] = self 132 | 133 | def try_move(self, direction: Tuple[int, int]) -> bool: 134 | next_pos = self.get_next_pos(direction) 135 | if not next_pos.valid: 136 | return False 137 | checked_tile = self.world.spatial_matrix[next_pos.y][next_pos.x] 138 | if not checked_tile: 139 | self.move(next_pos.x, next_pos.y, None) 140 | return True 141 | elif checked_tile.density < self.density: 142 | checked_tile.x = self.x 143 | checked_tile.y = self.y 144 | checked_tile.last_update = self.world.update_count 145 | self.move(next_pos.x, next_pos.y, replacement_tile=checked_tile) 146 | return True 147 | return False 148 | 149 | def check_directions(self, directions: Iterable[Tuple[int, int]]): 150 | if self._cooldown == 0: 151 | for direction in directions: 152 | if self.try_move(direction): 153 | self._skip_update = 0 154 | self.last_update = self.world.update_count 155 | return 156 | else: 157 | self._cooldown -= 1 158 | return 159 | if self._skip_update != self._MAX_UPDATE_SKIP: 160 | self._skip_update += 1 161 | self._cooldown = self._skip_update 162 | 163 | def update_position(self): 164 | raise NotImplemented 165 | 166 | 167 | class HeatTile(Tile): 168 | 169 | UPPER_HEATH_THRESHOLD: Tuple[int, Type[Tile]] or None = None 170 | LOWER_HEATH_THRESHOLD: Tuple[int, Type[Tile]] or None = None 171 | 172 | check_thresholds: Callable 173 | 174 | def __init__( 175 | self, 176 | color: Tuple[int, int, int], 177 | density: int, 178 | world: "World", 179 | x: int, 180 | y: int, 181 | base_heat: int = 25, 182 | heat_transfer_coefficient: float = 1, 183 | passive_heat_loss: int = 0 184 | ): 185 | super().__init__(color, density, world, x, y) 186 | self.heat = base_heat 187 | self.heat_transfer_coefficient = heat_transfer_coefficient 188 | self.passive_heath_loss = passive_heat_loss 189 | # optimize threshold check 190 | if self.UPPER_HEATH_THRESHOLD and (not self.LOWER_HEATH_THRESHOLD): 191 | self.check_thresholds = self.check_upper_threshold 192 | elif (not self.UPPER_HEATH_THRESHOLD) and self.LOWER_HEATH_THRESHOLD: 193 | self.check_thresholds = self.check_lower_threshold 194 | elif self.UPPER_HEATH_THRESHOLD and self.LOWER_HEATH_THRESHOLD: 195 | self.check_thresholds = self.check_both_thresholds 196 | else: 197 | self.check_thresholds = self.check_no_threshold 198 | 199 | def add(self): 200 | super().add() 201 | self.world.heat_tiles.append(self) 202 | 203 | def delete(self): 204 | super().delete() 205 | self.world.heat_tiles.remove(self) 206 | 207 | def check_no_threshold(self) -> bool: 208 | return False 209 | 210 | def check_upper_threshold(self) -> bool: 211 | if self.heat >= self.UPPER_HEATH_THRESHOLD[0]: 212 | if self.UPPER_HEATH_THRESHOLD[1]: 213 | new_tile = self.transform(self.UPPER_HEATH_THRESHOLD[1]) 214 | if new_tile: 215 | new_tile.heat = self.heat 216 | return True 217 | self.remove() 218 | return True 219 | return False 220 | 221 | def check_lower_threshold(self) -> bool: 222 | if self.heat <= self.LOWER_HEATH_THRESHOLD[0]: 223 | if self.LOWER_HEATH_THRESHOLD[1]: 224 | new_tile = self.transform(self.LOWER_HEATH_THRESHOLD[1]) 225 | if new_tile: 226 | new_tile.heat = self.heat 227 | return True 228 | self.remove() 229 | return True 230 | return False 231 | 232 | def check_both_thresholds(self) -> bool: 233 | return self.check_upper_threshold() or self.check_lower_threshold() 234 | 235 | def exchange_heat(self, target_tile: "HeatTile"): 236 | htc: float = self.heat_transfer_coefficient + target_tile.heat_transfer_coefficient 237 | exchanged_heat = int((target_tile.heat - self.heat) * htc) >> 2 238 | self.heat += exchanged_heat 239 | target_tile.heat -= exchanged_heat 240 | 241 | @cache 242 | def can_tile_exchange_heat(self, tile): 243 | return (tile is not None) and (tile in self.world.heat_tiles) 244 | 245 | def do_exchange_heat(self): 246 | self.heat -= self.passive_heath_loss 247 | for direction in Dir.ALL: 248 | tile: Tile = self.get_neighbour_tile(direction) 249 | if self.can_tile_exchange_heat(tile): 250 | self.exchange_heat(tile) 251 | self.check_thresholds() 252 | 253 | def update_temperature(self): 254 | raise NotImplemented 255 | 256 | 257 | class CustomTile(Tile): 258 | 259 | def add(self): 260 | super().add() 261 | self.world.custom_tiles.append(self) 262 | 263 | def delete(self): 264 | super().delete() 265 | self.world.custom_tiles.remove(self) 266 | 267 | def custom_update(self): 268 | raise NotImplemented 269 | 270 | 271 | class GenericSystem: 272 | 273 | NAME: str 274 | 275 | def __init__(self, world: "World"): 276 | self.world = world 277 | 278 | def update(self): 279 | raise NotImplemented 280 | 281 | 282 | class MovementSystem(GenericSystem): 283 | 284 | NAME = "Movement System" 285 | 286 | def update(self): 287 | for tile in self.world.moving_tiles: 288 | if tile.last_update != self.world.update_count: 289 | tile.update_position() 290 | 291 | 292 | class HeathSystem(GenericSystem): 293 | 294 | NAME = "Heath System" 295 | 296 | def update(self): 297 | for tile in self.world.heat_tiles: 298 | tile.update_temperature() 299 | 300 | 301 | class CustomTileSystem(GenericSystem): 302 | 303 | NAME = "Custom Tile System" 304 | 305 | def update(self): 306 | for tile in self.world.custom_tiles: 307 | tile.custom_update() 308 | 309 | 310 | class World: 311 | 312 | def __init__(self, width: int, height: int): 313 | self.width = width 314 | self.height = height 315 | # init tile lists 316 | self.tiles: List[Tile] = [] 317 | self.moving_tiles: List[MovingTile] = [] 318 | self.heat_tiles: List[HeatTile] = [] 319 | self.custom_tiles: List[CustomTile] = [] 320 | self.tiles_to_delete: List[Tile] = [] 321 | self.tiles_to_add: List[Tile] = [] 322 | # init world matrices 323 | init_matrix: List[List[Tile or None]] = [] 324 | for _ in range(height): 325 | init_matrix.append([None for _ in range(width)]) 326 | self.spatial_matrix: Tuple[List[Tile], ...] = tuple(init_matrix) 327 | print(f"world size: x {len(self.spatial_matrix[0])}, y {len(self.spatial_matrix)}") 328 | # init systems 329 | self.systems: Iterable[GenericSystem] = ( 330 | MovementSystem(self), 331 | HeathSystem(self), 332 | CustomTileSystem(self) 333 | ) 334 | self.update_count: int = 0 335 | 336 | def add_tile(self, tile_type: type, x: int, y: int) -> Tile: 337 | """ adds a tile at the given position and returns it """ 338 | new_tile: Tile = tile_type(self, x, y) 339 | if not self.spatial_matrix[y][x]: 340 | new_tile.add() 341 | return new_tile 342 | 343 | def delete_tile(self, x: int, y: int) -> Tile: 344 | """ Removes a tile at the given position and returns it """ 345 | tile = self.spatial_matrix[y][x] 346 | if tile: 347 | tile.remove() 348 | return tile 349 | 350 | def update(self): 351 | # update systems 352 | for system in self.systems: 353 | system.update() 354 | # delete tiles that need to be deleted 355 | if self.tiles_to_delete: 356 | for tile in self.tiles_to_delete: 357 | tile.delete() 358 | del tile 359 | self.tiles_to_delete.clear() 360 | # add tiles that need to be added 361 | if self.tiles_to_add: 362 | for tile in self.tiles_to_add: 363 | tile.add() 364 | del tile 365 | self.tiles_to_add.clear() 366 | self.update_count += 1 367 | 368 | 369 | # Tile types -------------------------------------- 370 | 371 | class SolidTile(HeatTile): 372 | 373 | def update_temperature(self): 374 | self.do_exchange_heat() 375 | 376 | 377 | class SemiSolidTile(HeatTile, MovingTile): 378 | 379 | DIRECTIONS = (Dir.DOWN, Dir.DOWN_LEFT, Dir.DOWN_RIGHT) 380 | 381 | def update_position(self): 382 | self.check_directions(self.DIRECTIONS) 383 | 384 | def update_temperature(self): 385 | self.do_exchange_heat() 386 | 387 | 388 | class LiquidTile(HeatTile, MovingTile): 389 | 390 | DIRECTIONS = ( 391 | (Dir.DOWN, Dir.DOWN_LEFT, Dir.LEFT, Dir.DOWN_RIGHT, Dir.RIGHT), 392 | (Dir.DOWN, Dir.DOWN_RIGHT, Dir.RIGHT, Dir.DOWN_LEFT, Dir.LEFT) 393 | ) 394 | 395 | def update_position(self): 396 | self.check_directions(self.DIRECTIONS[randint(2)]) 397 | 398 | def update_temperature(self): 399 | self.do_exchange_heat() 400 | 401 | 402 | class GasTile(HeatTile, MovingTile): 403 | 404 | DIRECTIONS = ( 405 | (Dir.UP, Dir.UP_LEFT, Dir.LEFT, Dir.UP_RIGHT, Dir.RIGHT), 406 | (Dir.UP, Dir.UP_RIGHT, Dir.RIGHT, Dir.UP_LEFT, Dir.LEFT) 407 | ) 408 | 409 | def update_position(self): 410 | self.check_directions(self.DIRECTIONS[randint(2)]) 411 | 412 | def update_temperature(self): 413 | self.do_exchange_heat() 414 | --------------------------------------------------------------------------------