├── 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 |
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 |
--------------------------------------------------------------------------------