├── .gitignore ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── pyproject.toml ├── setup.cfg └── src └── gemini ├── __init__.py ├── camera.py ├── input.py └── utils.py /.gitignore: -------------------------------------------------------------------------------- 1 | */__pycache__/* 2 | *.py[cod] 3 | dist/* 4 | src/gemini_engine.egg-info/* 5 | *.DS_Store 6 | setup.sh -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | RedPenguin#8749. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Red Penguin 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 | # Gemini Engine 2 | 3 | [![PyPI version](https://img.shields.io/pypi/v/gemini-engine?logo=pypi)](https://pypi.org/project/gemini-engine) 4 | ![Stars](https://img.shields.io/github/stars/renpenguin/GeminiEngine?color=yellow) ![Last commit](https://img.shields.io/github/last-commit/renpenguin/geminiengine) ![Code size](https://img.shields.io/github/languages/code-size/renpenguin/GeminiEngine) [![Downloads](https://img.shields.io/pypi/dm/gemini-engine)](https://pypi.org/project/gemini-engine) [![Issues](https://img.shields.io/github/issues/renpenguin/geminiengine)](https://github.com/renpenguin/GeminiEngine/issues) 5 | 6 | Gemini Engine is a monospace 2D ASCII rendering engine. It includes collisions, layers, inputs and the ability to handle solid objects as well as ascii art. Examples can be found on the [GeminiExamples github](https://github.com/renpenguin/GeminiExamples) 7 | 8 | WARNING: It’s important to use a monospace font in the terminal for the engine to render images properly 9 | 10 | ## Quick start 11 | 12 | Gemini Engine can be installed using pip: 13 | 14 | ``` 15 | python3 -m pip install -U gemini-engine 16 | ``` 17 | 18 | If you want to run the latest version of the code, you can install from github: 19 | 20 | ``` 21 | python3 -m pip install -U git+https://github.com/renpenguin/GeminiEngine.git@latest 22 | ``` 23 | 24 | Now that you have installed the library, instance a Scene and an Entity, then render the scene 25 | 26 | ```py 27 | from gemini import Scene, Entity 28 | 29 | scene = Scene(size=(20,10)) 30 | entity = Entity(pos=(5,5), size=(2,1), parent=scene) 31 | 32 | scene.render() 33 | ``` 34 | 35 | You should get something like this in your console: 36 | ![Gemini example 1](https://i.imgur.com/57daGVq.png) 37 | 38 | Look at that! You just made your first Gemini project! Now try adding a while loop to the end of your code 39 | ```py 40 | from gemini import Scene, Entity, sleep 41 | 42 | scene = Scene(size=(20,10)) 43 | entity = Entity(pos=(5,5), size=(2,1), parent=scene) 44 | 45 | while True: 46 | entity.move((1,0)) 47 | scene.render() 48 | sleep(.1) 49 | ``` 50 | 51 | Now the entity should be moving across the screen! When the entity goes out of the screen's bounds it will loop back round to the other side. 52 | 53 | ## Sprites 54 | 55 | The code below will animate a car moving across the screen: 56 | ```py 57 | from gemini import Scene, Sprite, sleep 58 | 59 | car_image = """ 60 | ______ 61 | /|_||_\`.__ 62 | (¶¶¶_¶¶¶¶_¶_\\ 63 | =`-(_)--(_)-' 64 | """ 65 | 66 | scene = Scene((30,10), is_main_scene=True) 67 | car = Sprite((5,5), car_image) 68 | 69 | while True: 70 | scene.render() 71 | car.move(1,0) 72 | sleep(.1) 73 | ``` -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=42"] 3 | build-backend = "setuptools.build_meta" -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = gemini-engine 3 | version = 1.1.3 4 | author = RedPenguin 5 | description = A monospace 2D ASCII rendering engine 6 | long_description = file: README.md 7 | long_description_content_type = text/markdown 8 | url = https://github.com/renpenguin/GeminiEngine 9 | project_urls = 10 | Examples = https://github.com/renpenguin/GeminiExamples 11 | Bug Tracker = https://github.com/renpenguin/GeminiEngine/issues 12 | classifiers = 13 | Programming Language :: Python :: 3 14 | License :: OSI Approved :: MIT License 15 | Operating System :: OS Independent 16 | 17 | [options] 18 | package_dir = 19 | = src 20 | packages = find: 21 | python_requires = >=3.10 22 | 23 | [options.packages.find] 24 | where = src 25 | -------------------------------------------------------------------------------- /src/gemini/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | from .utils import main_scene, txtcolours, printd, Vec2D, force_types, sleep 3 | from .input import Input 4 | from .camera import Camera 5 | from . import utils 6 | 7 | # -- Entities -- 8 | 9 | class RawEntity: 10 | """## RawEntity 11 | Barebones entity object for custom objects. no collisions, no `move()`, no size, just the barebones""" 12 | 13 | _parent = None 14 | 15 | @property 16 | def parent(self): 17 | return self._parent 18 | @parent.setter 19 | def parent(self, value: 'Scene'): 20 | if (self._parent != value or value is None) and self._parent: 21 | self._parent.children.remove(self) 22 | if value != None: 23 | value.add_to_scene(self) 24 | 25 | @property 26 | def pos(self): 27 | return Vec2D(self._pos) 28 | @pos.setter 29 | def pos(self, value: Vec2D): 30 | self._pos = Vec2D(value) % self.parent.size if self.parent else Vec2D(value) 31 | @property 32 | def all_positions(self): 33 | return [] 34 | 35 | def __init__(self, pos: Vec2D, parent: 'Scene', layer, colour, visible): 36 | self._parent: 'Scene' = None 37 | if parent := parent or main_scene.main_scene: 38 | self.parent = parent 39 | 40 | self.pos = pos 41 | self.visible = visible 42 | self.layer, self.colour = layer, colour 43 | 44 | def get_pixel(self, pos: Vec2D) -> str: 45 | """All children of this class should have return a character using the get_pixel function""" 46 | return "█" 47 | 48 | class Entity(RawEntity): 49 | """## Entity 50 | The Entity is the most basic object in a Gemini Scene. It is simply a rectangle of your chosen proportions. You can create a new entity like so. 51 | 52 | >>> from gemini import Scene, Entity 53 | >>> new_scene = Scene((30,15)) 54 | >>> new_entity = Entity(pos=(5,4), size=(2,1), parent=new_scene) 55 | 56 | You can also set all entities created to have the same parent like this: 57 | >>> new_scene = Scene((30,15), is_main_scene=True) 58 | >>> new_entity = Entity((5,4), (2,1)) 59 | """ 60 | 61 | @property 62 | def fill_char(self): 63 | return self._fill_char 64 | @fill_char.setter 65 | def fill_char(self, value: str): 66 | self._fill_char = value 67 | @property 68 | def all_positions(self): 69 | return [((self.pos + (i,j)) % self.parent.size) for i in range(self.size[0]) for j in range(self.size[1])] 70 | 71 | def __init__(self, pos: Vec2D, size: Vec2D, parent: 'Scene'=None, auto_render:bool=False, layer: int=0, fill_char:str="█", colour:str="", collisions: list[int]|bool=[], visible:bool=True, move_functions: list=[]): 72 | super().__init__(pos, parent, layer, colour, visible) 73 | 74 | self.size, self.fill_char = Vec2D(size), fill_char 75 | self.auto_render= auto_render 76 | self.collisions: list = [-1] if collisions == True else [] if collisions == False else collisions 77 | self.move_functions: list[function] = move_functions 78 | 79 | def __str__(self): 80 | return f"Entity(pos={self.pos},size={self.size},fill_char='{self._fill_char}')" 81 | 82 | def move(self, x:int|tuple, y:int=None, collide: bool=None, run_functions=True, render: bool=None): 83 | """Move the Entity within the scene. `+x` is right and `+y` is down. By enabling the Entity's auto_render property, calling this function will automatically render the scene that this Entity belongs to. If your scene is stuttering while animating, make sure you're only rendering the scene once per frame. 84 | 85 | When collisions are on, the entity will collide with anything that isnt the background""" 86 | if render is None: 87 | render = self.auto_render 88 | 89 | has_collided = False 90 | 91 | move = Vec2D(x, y) 92 | 93 | if move.x != 0 or move.y != 0: 94 | if collide is None: 95 | collide = self.collisions 96 | if collide: 97 | positions = self.all_positions 98 | def step_collide(axis: utils.Axis, p): 99 | if p == 0: 100 | return 101 | colliding = abs(p) 102 | polarity = (1 if p > 0 else -1) 103 | for j in range(colliding): 104 | for pos in positions: 105 | new_pos = pos + axis.vector((j+1)*polarity) 106 | collisions = self.parent.get_entities_at(new_pos, collide) 107 | collisions = list(filter(lambda x: self != x, collisions)) 108 | if collisions: 109 | nonlocal has_collided 110 | colliding, has_collided = j, True 111 | 112 | if colliding < abs(p): 113 | break 114 | self.pos += axis.vector(colliding * polarity) 115 | 116 | step_collide(utils.Axis.X, move.x) 117 | step_collide(utils.Axis.Y, move.y) 118 | else: 119 | self.pos += move 120 | 121 | if run_functions and not has_collided: 122 | for func in self.move_functions: 123 | func() 124 | 125 | if render: 126 | self.parent.render() 127 | 128 | return 1 if has_collided else 0 129 | 130 | def get_pixel(self, _): 131 | return self.fill_char 132 | 133 | class Point(Entity): 134 | """## Point 135 | A child of `Entity` with size (1,1). Helpful for temporary points in renders, simply add `gemini.Point.clear_points(scene)` to `scene.render_functions` (`scene` being your Scene instance)""" 136 | 137 | @property 138 | def all_positions(self): 139 | return [self.pos] 140 | 141 | def __init__(self, pos: Vec2D, *args, **kwargs): 142 | super().__init__(pos, (1,1), *args, **kwargs) 143 | 144 | def __str__(self): 145 | return f"Point(pos={self.pos},fill_char='{self._fill_char}')" 146 | 147 | class Line(RawEntity): 148 | """## Line 149 | An object to handle automatic generation of lines. Accepts a `pos1` and a `pos2` variable. Inherits from `RawEntity`. Lines are generated using Bresenham's line algorithm 150 | """ 151 | 152 | @property 153 | def pos0(self): 154 | return Vec2D(self._pos0) 155 | @pos0.setter 156 | def pos0(self, value: Vec2D): 157 | self._pos0 = Vec2D(value) 158 | 159 | @property 160 | def pos1(self): 161 | return Vec2D(self._pos1) 162 | @pos1.setter 163 | def pos1(self, value: Vec2D): 164 | self._pos1 = Vec2D(value) 165 | 166 | @property 167 | def all_positions(self): 168 | x0, y0 = self.pos0 169 | x1, y1 = self.pos1 170 | positions = [] 171 | dx = abs(x1 - x0) 172 | sx = 1 if x0 < x1 else -1 173 | dy = -abs(y1 - y0) 174 | sy = 1 if y0 < y1 else -1 175 | error = dx + dy 176 | 177 | while True: 178 | positions.append(Vec2D(x0, y0) % self.parent.size) 179 | e2 = error * 2 180 | if e2 >= dy: 181 | if x0 == x1: break 182 | error += dy 183 | x0 += sx 184 | if e2 <= dx: 185 | if y0 == y1: break 186 | error += dx 187 | y0 += sy 188 | 189 | return positions 190 | 191 | def __init__(self, pos0: Vec2D, pos1: Vec2D, parent: 'Scene' = None, layer: int = 0, fill_char: str = "█", colour: str = "", visible: bool = True): 192 | super().__init__(pos0, parent, layer, colour, visible) 193 | self.fill_char = fill_char 194 | self.pos0 = pos0 195 | self.pos1 = pos1 196 | 197 | def __str__(self): 198 | return f"Line(pos0={self.pos0},pos1={self.pos1},fill_char='{self._fill_char}')" 199 | 200 | def get_pixel(self, _): 201 | return self.fill_char 202 | 203 | class Polygon(RawEntity): 204 | """## Polygon 205 | A shape generated by a set of points based on RawEntity""" 206 | @property 207 | def vertices(self): 208 | return [Vec2D(v) for v in self._vertices] 209 | @vertices.setter 210 | def vertices(self, value: Vec2D): 211 | self._vertices = [Vec2D(v) for v in value] 212 | 213 | @property 214 | def all_positions(self): 215 | positions = [] 216 | if len(self.vertices) > 2: 217 | x_range = min(i.x for i in self.vertices), max(i.x for i in self.vertices) 218 | y_range = min(i.y for i in self.vertices), max(i.y for i in self.vertices) 219 | 220 | def is_inside(pos): 221 | ray = (Vec2D(x_range[0] - 2, pos[1]), pos) 222 | count = 0 223 | for line in lines: 224 | if utils.intersect(ray[0], ray[1], line[0], line[1]): 225 | count += 1 226 | return count % 2 == 1 227 | 228 | lines = [(self.vertices[i], self.vertices[i+1]) for i in range(len(self.vertices)-1)] + [(self.vertices[-1], self.vertices[0])] 229 | positions = [Vec2D(x, y) for x in range(x_range[0], x_range[1]) for y in range(y_range[0], y_range[1])] 230 | positions = list(filter(is_inside, positions)) 231 | return positions 232 | 233 | def __init__(self,vertices:list[Vec2D],parent:'Scene'=None,layer:int=0,fill_char:str="█",colour:str="",visible:bool=True): 234 | self.vertices = vertices 235 | self.fill_char = fill_char 236 | super().__init__(self.vertices[0], parent, layer, colour, visible) 237 | 238 | def get_pixel(self, _): 239 | return self.fill_char 240 | 241 | class Sprite(Entity): 242 | """## Sprite 243 | An entity with a give ASCII art that is rendered on the Scene, this can be used to put text on the scene, like so: 244 | >>> from gemini import Scene, Sprite 245 | >>> scene = Scene((13,3)) 246 | >>> text = Sprite((1,1), image="Hello there", parent=scene, transparent=False) 247 | >>> scene.render() 248 | ░░░░░░░░░░░░░ 249 | ░Hello there░ 250 | ░░░░░░░░░░░░░ 251 | 252 | This makes it easy to put existing ascii art into whatever you're making, and move it around! 253 | 254 | In the event that a single character takes up two spaces (e.g. ¯\_(ツ)_/¯), you can use the `extra_characters` parameter, with each index of the list corresponding to the line with the extra character. For instance with a sprite with the image `¯\_(ツ)_/¯`, you would set `extra_characters=[1]` 255 | """ 256 | 257 | @property 258 | def image(self): 259 | """This will return nothing if the sprite is hidden, to always get the raw image""" 260 | return self._image 261 | @image.setter 262 | def image(self, value: str): 263 | self._image = value 264 | self.size = Vec2D(len(max(value.split("\n"), key= lambda x: len(x))), value.count("\n") + 1) 265 | 266 | @property 267 | def render_image(self): 268 | """Regular image but with the extra characters""" 269 | render_image = self.image.split("\n") 270 | for i, n in enumerate(self.extra_characters): 271 | render_image[i] += "​"*n # Add zero width spaces 272 | return '\n'.join(render_image) 273 | @property 274 | def all_positions(self): 275 | raw_positions = [] 276 | for j in range(self.size[1]): 277 | length = self.size[0] + ( 278 | self.extra_characters[j] if len(self.extra_characters) > j else 0 279 | ) 280 | raw_positions.extend([(i,j) for i in range(length)]) 281 | return [ 282 | (self.pos + pos) % self.parent.size for pos in raw_positions 283 | if self.get_pixel(pos) != " " or not self.transparent 284 | ] 285 | 286 | def __init__(self, pos: Vec2D, image: str, transparent: bool=True, extra_characters: list=[], *args, **kwargs): 287 | super().__init__(pos, (0,0), *args, **kwargs) # dummy size parameter 288 | self.transparent = transparent 289 | self.extra_characters = extra_characters 290 | self.image = image.strip("\n") 291 | del self._fill_char 292 | 293 | def __str__(self): 294 | return f"Sprite(pos={self.pos},image='{self._image[:10]}{'...' if len(self._image) > 10 else ''}')" 295 | 296 | def get_pixel(self, pos: Vec2D) -> str: 297 | try: 298 | return self.render_image.split("\n")[pos[1]][pos[0]] 299 | except Exception: 300 | return " " 301 | 302 | class AnimatedSprite(Sprite): 303 | """## AnimatedSprite 304 | The AnimatedSpite object works the same way as the Sprite class, but accepts a list of images instead of only one, and can be set which to show with the `current_frame` value. The `image` property will now return the current frame as an image 305 | 306 | This here will create an AnimatedSprite PacMan whose mouth will change every time the move function is used: 307 | >>> from gemini import Scene, AnimatedSprite 308 | scene = Scene((10,10)) 309 | new_entity = AnimatedSprite((5,5), ["O","C","<","C"], parent) 310 | new_entities.move_functions.append(new_entity.next_frame,) 311 | ```""" 312 | 313 | @property 314 | def current_frame(self): 315 | """Returns the index of the current frame, to get a picture of the actual frame use self.image. 316 | When setting the current_frame, the index will always autocorrect itself to be within the fram list's range.""" 317 | return self._current_frame 318 | @current_frame.setter 319 | def current_frame(self, value: int): 320 | self._current_frame = value % len(self.frames) 321 | self.image = self.frames[self._current_frame] 322 | 323 | def __init__(self, pos: Vec2D, frames: list, *args, **kwargs): 324 | self.frames = [frame.strip("\n") for frame in frames] 325 | self._current_frame = 0 326 | 327 | super().__init__(pos, frames[0], *args, **kwargs) 328 | 329 | def __str__(self): 330 | return f"AnimatedSprite(pos={self.pos},frames='{str(self.frames)[:20]}')" 331 | 332 | def next_frame(self): 333 | self.current_frame += 1 334 | 335 | def prev_frame(self): 336 | self.current_frame -= 1 337 | 338 | # -- Scene -- 339 | 340 | class Scene: 341 | """## Scene 342 | You can attach entities to this scene and render the scene to display them. There can be more than one scene that can be rendered one after the other. Create a scene like so: 343 | >>> from gemini import Scene 344 | >>> new_scene = Scene((30,15)) 345 | 346 | The width and height parameters are required and define the size of the rendered scene. To set the scene size to be the current terminal size, by using `os.get_terminal_size()` 347 | 348 | Using is_main_scene=True is the same as 349 | >>> from gemini import Scene, set_main_scene 350 | >>> new_scene = Scene((10,10)) 351 | >>> set_main_scene(new_scene) 352 | 353 | The `render_functions` parameter is to be a list of functions to run before any render, except when the `run_functions` parameter is set to False""" 354 | 355 | _void_char = '¶' 356 | debug_display = "" 357 | 358 | @property 359 | def origin(self): 360 | """Set where the centre of the screen should be. Can be a Vec2D or a string with one of the following options: 361 | - "topleft" 362 | - "centre" 363 | """ 364 | return Vec2D(eval(str(self._origin), {"topleft": (0,0), "centre": self.size/2})) 365 | 366 | @origin.setter 367 | def origin(self, value): 368 | self._origin = value 369 | 370 | @property 371 | def is_main_scene(self): 372 | return main_scene.main_scene == self 373 | @is_main_scene.setter 374 | def is_main_scene(self, value): 375 | main_scene.main_scene = self if value else None 376 | 377 | @property 378 | def background_tile(self): 379 | """Return the background character with colours included""" 380 | return f"{self.bg_colour}{self.clear_char}{txtcolours.END if self.bg_colour != '' else ''}" 381 | 382 | def __init__(self, size: Vec2D, clear_char="░", bg_colour="", children: list[RawEntity]=[], render_functions: list=None, is_main_scene=False, origin="topleft"): 383 | self.size = Vec2D(size) 384 | self.clear_char = clear_char 385 | self.bg_colour = bg_colour 386 | self.children: list[RawEntity] = [] 387 | self.render_functions: list[function] = render_functions if render_functions != None else [self.clear_points] 388 | self.origin = origin 389 | 390 | if is_main_scene: 391 | self.is_main_scene = True 392 | 393 | for child in children[:]: 394 | self.add_to_scene(child) 395 | 396 | def __str__(self): 397 | return f"Scene(size={self.size},clear_char='{self.clear_char}',is_main_scene={self.is_main_scene})" 398 | 399 | def add_to_scene(self, new_entity: RawEntity): 400 | """Add an entity to the scene. This can be used instead of directly defining the entity's parent, or if you want to move the entity between different scenes""" 401 | self.children.append(new_entity) 402 | new_entity._parent = self 403 | 404 | def clear_points(self): 405 | """Remove all `Point` objects""" 406 | for point in filter(lambda x: isinstance(x, Point), self.children[:]): 407 | point.parent = None 408 | 409 | def get_separator(self, used_lines=None): 410 | """Create a separator to put above display so that you can only see one rendered scene at a time [[DEPRECATED]]""" 411 | 412 | return "\n" * (os.get_terminal_size().lines - (used_lines or self.size[1])) 413 | 414 | def _render_stage(self, stage: list[list], show_coord_numbers=False, use_rewrite=True, use_clear=False, starting_coords=(0,0)): 415 | """Return a baked scene, ready for printing. This will take your grid of strings and render it. You can also set `show_coord_numbers=True` to print your scene with coordinate numbers for debugging purposes""" 416 | 417 | if show_coord_numbers: 418 | for i, c in enumerate(stage, starting_coords[1]): 419 | c.insert(0, str(i)[-1:]) 420 | stage.insert( 0, [' '] + [ str(starting_coords[0]+i)[-1:] for i in range(len(stage[0])-1) ] ) 421 | 422 | rows = ["".join(row) for row in stage] 423 | visible_lines = 0 if os.get_terminal_size().lines > self.size.y else self.size.y - os.get_terminal_size().lines + 2 424 | return ("\x1b[H" if use_rewrite else "") + ("\x1b[J" if use_clear else "") + "\n".join(rows[visible_lines:]) + "\x1b[J\n" 425 | 426 | def render(self, is_display=True, layers: list=None, run_functions=True, *, _output=True, show_coord_numbers=False, use_rewrite=True, use_clear=False): 427 | """This will print out all the entities that are part of the scene with their current settings. The character `¶` can be used as a whitespace in Sprites, as regular ` ` characters are considered transparent, unless the transparent parameter is disabled, in which case all whitespaces are rendered over the background. 428 | 429 | When rendering an animation, make sure to put a short pause in between frames to set your fps. `gemini.sleep(0.1)` will mean a new fram every 0.1 seconds, aka 10 FPS 430 | 431 | If your scene is stuttering while animating, make sure you're only rendering the scene once per frame 432 | 433 | If the `layers` parameter is set, only entities on those layers will be rendered. Entities will also be rendered in the order of layers, with the smallest layer first 434 | 435 | For debugging, you can set `show_coord_numbers=True` to more see coordinate numbers around the border of your rendered scene. These numbers will not show in the render function's raw output regardless 436 | 437 | `reprint_render` determines if the render will replace the old render or simply print after it. The latter will allow you to scroll up and see your screen history 438 | """ 439 | 440 | stage = [[self.background_tile] * self.size[0] for _ in range(self.size[1])] # Create the render 'stage' 441 | entity_list = list(filter(lambda x: x.layer in layers, self.children)) if layers and layers != [-1] else self.children # Get a list of the entities the user wants to render 442 | entity_list = list(filter(lambda x: x.visible, entity_list)) 443 | for entity in sorted(entity_list, key=lambda x: x.layer, reverse=True): 444 | # Add each pixel of an entity to the stage 445 | end = txtcolours.END if entity.colour else '' 446 | for position in entity.all_positions: 447 | position += self.origin 448 | position %= self.size 449 | pixel = entity.get_pixel( 450 | (position - entity.pos - self.origin) % self.size 451 | )[0].replace(self._void_char,' ') 452 | stage[position[1]][position[0]] = f"{entity.colour}{pixel}{end}" 453 | 454 | for i, line in enumerate(self.debug_display.split("\n")): 455 | for j in range(len(line)): 456 | stage[i][j] = line[j] 457 | 458 | if run_functions: 459 | for function in self.render_functions: 460 | function() 461 | 462 | if is_display: 463 | print(self._render_stage(stage, show_coord_numbers, use_rewrite, use_clear)) 464 | if _output: 465 | return stage 466 | 467 | def is_entity_at(self, pos: Vec2D, layers: list=[-1], exclude:list[RawEntity]=[]): 468 | """Check for any object at a specific position, can be sorted by layers. `-1` in the layers list means to collide with all layers. entities in the `exclude` list parameter will be ignored""" 469 | return len(list(filter(lambda x: x not in exclude, self.get_entities_at(pos, layers)))) > 0 470 | 471 | def get_entities_at(self, pos: Vec2D, layers: list[int]=[]) -> list[Entity]: 472 | """Return all entities found at the chosen position, can be filtered by layer""" 473 | layers = layers if isinstance(layers, list) else [layers] 474 | layers = layers if layers != [-1] else [] 475 | entities: list[Entity] = list(filter(lambda x: x.layer in layers, self.children)) if layers else self.children 476 | 477 | return list(filter(lambda x: pos in x.all_positions, entities)) 478 | 479 | # prepares for the first render 480 | print("\n" * (os.get_terminal_size().lines)) -------------------------------------------------------------------------------- /src/gemini/camera.py: -------------------------------------------------------------------------------- 1 | from . import utils 2 | 3 | class Camera: 4 | """## Camera 5 | An object to render a specific area of a scene. It will center on the location you set, or track any entity you choose 6 | 7 | >>> from gemini import Scene, Entity, Camera 8 | >>> scene = Scene((20,10), is_main_scene=True) 9 | >>> block = Entity((6,3), (3,3)) 10 | >>> camera = Camera((0,0), (5,5), focus_object=block) 11 | >>> scene.render(use_separator=False) # renders scene as usual 12 | >>> camera.render(use_separator=False) # renders scene from camera""" 13 | @property 14 | def pos(self): 15 | return self.focus_object.pos + self.focus_object.size/2 if self.focus_object else self._pos 16 | @pos.setter 17 | def pos(self, value: utils.Vec2D): 18 | self._pos = value 19 | self.focus_object = None # not sure about this 20 | 21 | # @utils.force_types(skip=1) 22 | def __init__(self, pos: utils.Vec2D, size: utils.Vec2D, focus_object=None, scene=None): 23 | self.pos = pos 24 | self.size = size 25 | self.focus_object = focus_object 26 | 27 | if not scene: 28 | scene = utils.main_scene.main_scene 29 | if focus_object: 30 | scene = self.focus_object.parent 31 | self.scene = scene 32 | 33 | def __str__(self) -> str: 34 | pos = f"focus_object={self.focus_object}" if self.focus_object else f"pos={self.pos}" 35 | return f"Camera({pos}, size={self.size})" 36 | 37 | def render(self, is_display=True, *args, _output=True, show_coord_numbers=False, use_rewrite=True, use_clear=False, **kwargs): 38 | """Render a scene through a camera. All `Scene.render` parameters can be used""" 39 | 40 | image = self.scene.render(False, *args, **kwargs) 41 | 42 | top_left = tuple(map(lambda x,y: x-int(y/2), self.pos, self.size)) 43 | bot_right = tuple(map(lambda x,y: x+int(y/2) + y % 2, self.pos, self.size)) 44 | 45 | cut_vertical = image[max(top_left[1],0):bot_right[1]] 46 | stage = [ 47 | l[max(top_left[0],0):bot_right[0]] for l in cut_vertical 48 | ] 49 | 50 | if is_display: 51 | print(self.scene._render_stage(stage, show_coord_numbers, use_rewrite, use_clear, starting_coords=(max(top_left[0],0), max(top_left[1],0)))) 52 | if _output: 53 | return stage -------------------------------------------------------------------------------- /src/gemini/input.py: -------------------------------------------------------------------------------- 1 | import contextlib, sys, os 2 | from .utils import Vec2D, MorphDict 3 | 4 | class Input: 5 | """## Input 6 | The input class is used to collect inputs from the user. To wait for a key press before continuing the code use `Input().wait_for_key_press()` 7 | >>> from gemini import Input 8 | >>> if Input().wait_for_key_press() == "g": 9 | >>> print("You pressed the right key!") 10 | 11 | For while loops with a wait function where you want to also check for inputs, call the input class directly after the wait function: 12 | >>> while True: 13 | >>> sleep(0.1) 14 | >>> input = Input().pressed_key 15 | >>> # Other processes 16 | 17 | you can also compare your input to `Input.direction_keys` to get a vector, so you can do this! 18 | >>> my_input = Input() 19 | >>> entity.move(my_entity.get_key_press()) 20 | 21 | and the Entity will move in your chosen direction! 22 | """ 23 | _arrow_keys = {"a": "up","b": "down","c": "right","d": "left"} 24 | direction_keys = MorphDict( 25 | (["w","up_arrow"],Vec2D(0,-1)), 26 | (["s","down_arrow"],Vec2D(0,1)), 27 | (["a","left_arrow"],Vec2D(-1,0)), 28 | (["d","right_arrow"],Vec2D(1,0)) 29 | ) 30 | 31 | def __init__(self): 32 | self.pressed_key = self.get_key_press(False) 33 | 34 | def string_key(self, c: str | None) -> str: 35 | key = repr(c)[1:-1].lower() if c else None 36 | if key == "\\x1b" and self.get_key_press() == "[": 37 | key = f"{self._arrow_keys[self.get_key_press()]}_arrow" 38 | return key 39 | 40 | def get_key_press(self, is_wait=True) -> str: 41 | if os.name == "nt": 42 | from msvcrt import getch 43 | if is_wait: 44 | while True: 45 | if c := getch(): 46 | return self.string_key(c) 47 | else: 48 | c = getch() 49 | return self.string_key(c) 50 | else: 51 | import termios, fcntl 52 | fd = sys.stdin.fileno() 53 | 54 | oldterm = termios.tcgetattr(fd) 55 | newattr = termios.tcgetattr(fd) 56 | newattr[3] = newattr[3] & ~termios.ICANON & ~termios.ECHO 57 | termios.tcsetattr(fd, termios.TCSANOW, newattr) 58 | 59 | oldflags = fcntl.fcntl(fd, fcntl.F_GETFL) 60 | fcntl.fcntl(fd, fcntl.F_SETFL, oldflags | os.O_NONBLOCK) 61 | 62 | try: 63 | if is_wait: 64 | while True: 65 | with contextlib.suppress(IOError): 66 | if c := sys.stdin.read(1): 67 | return self.string_key(c) 68 | else: 69 | with contextlib.suppress(IOError): 70 | c = sys.stdin.read(1) 71 | return self.string_key(c) 72 | finally: 73 | termios.tcsetattr(fd, termios.TCSAFLUSH, oldterm) 74 | fcntl.fcntl(fd, fcntl.F_SETFL, oldflags) -------------------------------------------------------------------------------- /src/gemini/utils.py: -------------------------------------------------------------------------------- 1 | import sys, time, enum, math 2 | 3 | class MorphDict: 4 | """## MorphDict 5 | A dictionary that can have multiple keys for one value""" 6 | def __init__(self, *items: dict|list[tuple]): 7 | if type(items) is dict: 8 | items = dict.items() 9 | 10 | self.items = [([keys] if isinstance(keys, str|int) else list(keys), value) for keys, value in items] 11 | def __repr__(self): 12 | return self.items 13 | def __str__(self): 14 | return f"{self.items}" 15 | 16 | def keys(self): 17 | return [i[0] for i in self.items] 18 | def all_keys(self): 19 | return sum(self.keys(), start=[]) 20 | def values(self): 21 | return [i[1] for i in self.items] 22 | 23 | def __getitem__(self, i): 24 | return list(filter(lambda x: i in x[0], self.items))[0][1] 25 | def __setitem__(self, i, value): 26 | list(filter(lambda x: i in x[0], self.items))[0][1] = value 27 | 28 | def append(self, keys, value): 29 | self.items.append(([keys] if isinstance(keys, str|int) else list(keys), value)) 30 | 31 | class Axis(enum.Enum): 32 | """Helper class for the move function. """ 33 | X = 0 34 | Y = 1 35 | 36 | def vector(self, value, seconday_value=0) -> tuple: # My dad helped me with this :D 37 | """Useful for movements in single directions""" 38 | match self: 39 | case Axis.X: 40 | return Vec2D(value, seconday_value) 41 | case Axis.Y: 42 | return Vec2D(seconday_value, value) 43 | 44 | def printd(*texts: str, delay=0.01, skip_delay_characters=[" "]): 45 | """Delayed print function. A simple print function that can be used in place of the usual print to have your text print out character by character, like in text adventure games!""" 46 | for i in "".join(texts): 47 | sys.stdout.write(i) 48 | sys.stdout.flush() 49 | if i not in skip_delay_characters: 50 | time.sleep(delay) 51 | print() 52 | 53 | def sleep(secs: float): 54 | """Delay execution for a given amount seconds""" 55 | time.sleep(secs) 56 | 57 | def parametrized(dec): 58 | """Parameters for wrapper functions, like 59 | `@yourwrapper(param=True)`""" 60 | def layer(*args, **kwargs): 61 | def repl(f): 62 | return dec(f, *args, **kwargs) 63 | return repl 64 | return layer 65 | 66 | @parametrized 67 | def force_types(func, skip=0, ignore_types=[]): 68 | """Force function types 69 | 70 | `*args` should be fully hinted. Class functions (starting with self parameter) do not yet work""" 71 | def wraps(*args, **kwargs): 72 | args = list(args) 73 | keys_list = list(func.__annotations__.keys()) 74 | for i, a in enumerate(args[skip:], skip-1): 75 | arg_type = func.__annotations__[keys_list[i]] 76 | if callable(arg_type) and arg_type not in ignore_types: 77 | args[i] = arg_type(a) 78 | 79 | for k,v in kwargs.items(): 80 | if k in func.__annotations__: 81 | arg_type = func.__annotations__[k] 82 | if callable(arg_type): 83 | kwargs[k] = arg_type(v) 84 | return func(*args, **kwargs) 85 | wraps.__unwrapped__ = func 86 | return wraps 87 | 88 | class _MainScene: 89 | """Helper class for main scenes""" 90 | 91 | def __init__(self) -> None: 92 | self._main_scene = None 93 | 94 | @property 95 | def main_scene(self): 96 | return self._main_scene 97 | @main_scene.setter 98 | def main_scene(self, value): 99 | self._main_scene = value 100 | 101 | def __repr__(self) -> str: 102 | return self.main_scene 103 | def __str__(self) -> str: 104 | return str(self.main_scene) 105 | main_scene = _MainScene() 106 | 107 | # Vec2D 108 | 109 | class Vec2D: 110 | """Helper class for positions and sizes. A set of two ints. Can be initalised with `Vec2D(5,4)` or with `Vec2D([5,4])` Can also just be a replacement for `tuple[int,int]` 111 | 112 | Other examples: 113 | >>> Vec2D(5, 2) + Vec2D(4, -1) 114 | Vec2D(9, 1) 115 | >>> Vec2D(10, 10) - Vec2D(4,1) 116 | Vec2D(6, 9)""" 117 | 118 | def __init__(self, x: list|int, y:int=None): 119 | self.y = int(x[1] if isinstance(x, list|tuple|Vec2D) else y) 120 | self.x = int(x[0] if isinstance(x, list|tuple|Vec2D) else x) 121 | 122 | def __repr__(self): 123 | return (self.x, self.y) 124 | def __str__(self): 125 | return str(self.__repr__()) 126 | def __getitem__(self, i: int): 127 | if i > 1: 128 | raise IndexError("Vec2D has no elements outside of x and y") 129 | return self.__repr__()[i] 130 | def __add__(self, value: 'Vec2D'): 131 | return Vec2D(self[0]+value[0], self[1]+value[1]) 132 | __radd__ = __add__ 133 | def __sub__(self, value: 'Vec2D'): 134 | return Vec2D(self[0]-value[0], self[1]-value[1]) 135 | __rsub__ = __sub__ 136 | def __mul__(self, value: int): 137 | return Vec2D(self.x*value,self.y*value) 138 | __rmul__ = __mul__ 139 | def __truediv__(self, value: int): 140 | return Vec2D(self.x/value,self.y/value) 141 | def __mod__(self, limits: 'Vec2D'): 142 | return Vec2D(list(map( 143 | lambda x, y: x%y if x >= 0 else (y + x)%y, 144 | self, limits 145 | ))) 146 | def __eq__(self, value: 'Vec2D') -> bool: 147 | return self.__repr__() == Vec2D(value).__repr__() 148 | def __gt__(self, value: 'Vec2D') -> bool: 149 | return self.x > Vec2D(value).x or self.y > Vec2D(value).y 150 | def __lt__(self, value: 'Vec2D') -> bool: 151 | return self.x < Vec2D(value).x or self.y < Vec2D(value).y 152 | def __ge__(self, value: 'Vec2D') -> bool: 153 | return self.x >= Vec2D(value).x or self.y >= Vec2D(value).y 154 | def __le__(self, value: 'Vec2D') -> bool: 155 | return self.x <= Vec2D(value).x or self.y <= Vec2D(value).y 156 | 157 | class Vec2DFloat(Vec2D): 158 | """Helper class for positions and sizes. A set of two floats. Can be initalised with `Vec2DFloat(5.5,4.2)` or with `Vec2DFloat([5.5,4.2])` Can also just be a replacement for `tuple[float,float]` 159 | 160 | Other examples: 161 | >>> Vec2DFloat(5, 2) + Vec2DFloat(4, -1.5) 162 | Vec2DFloat(9.0, 0.5) 163 | >>> Vec2DFloat(10.3, 10) - Vec2DFloat(4.8,1) 164 | Vec2DFloat(5.5, 9)""" 165 | 166 | def __init__(self, x: list|float, y:float=None): 167 | self.y = float(x[1] if isinstance(x, list|tuple|Vec2D) else y) 168 | self.x = float(x[0] if isinstance(x, list|tuple|Vec2D) else x) 169 | 170 | def to_int(self): 171 | return Vec2D(self) 172 | 173 | def __add__(self, value: 'Vec2DFloat'): 174 | return Vec2DFloat(self[0]+value[0], self[1]+value[1]) 175 | __radd__ = __add__ 176 | def __sub__(self, value: 'Vec2DFloat'): 177 | return Vec2DFloat(self[0]-value[0], self[1]-value[1]) 178 | __rsub__ = __sub__ 179 | def __mul__(self, value: float): 180 | return Vec2DFloat(self.x*value,self.y*value) 181 | __rmul__ = __mul__ 182 | def __truediv__(self, value: float): 183 | return Vec2DFloat(self.x/value,self.y/value) 184 | def __mod__(self, limits: 'Vec2DFloat'): 185 | return Vec2DFloat(list(map( 186 | lambda x, y: x%y if x >= 0 else (y + x)%y, 187 | self, limits 188 | ))) 189 | 190 | def ccw(A,B,C): 191 | return (C.y-A.y) * (B.x-A.x) > (B.y-A.y) * (C.x-A.x) 192 | def intersect(A,B,C,D): 193 | """Return true if line segments AB and CD intersect""" 194 | return ccw(A,C,D) != ccw(B,C,D) and ccw(A,B,C) != ccw(A,B,D) 195 | 196 | def is_clockwise(points: list[Vec2D]): 197 | return sum((p1.x-p2.x)*(p1.y+p2.y) for p1, p2 in zip(points, points[-1:]+points[:-1])) < 0 198 | 199 | def hsv_to_rgb(h,s,v): 200 | """converts hsv to rgb, uses 0-255 range all around""" 201 | h /= 255 202 | s /= 255 203 | v /= 255 204 | i = math.floor(h*6) 205 | f = h*6 - i 206 | p = v * (1-s) 207 | q = v * (1-f*s) 208 | t = v * (1-(1-f)*s) 209 | 210 | r, g, b = [ 211 | (v, t, p), 212 | (q, v, p), 213 | (p, v, t), 214 | (p, q, v), 215 | (t, p, v), 216 | (v, p, q), 217 | ][int(i%6)] 218 | 219 | return r*255, g*255, b*255 220 | 221 | class txtcolours: 222 | """txtcolours can be used to set an entity's colour, like so: 223 | >>> from gemini import Scene, Entity, txtcolours as tc 224 | >>> scene = Scene((10,10)) 225 | >>> entity1 = Entity(pos=(3,1),size=(2,1),colour=tc.RED) 226 | 227 | this will make entity1 red. 228 | 229 | Important note: for windows users please use colorama, as `txtcolours` only works with ANSI terminals. You can use `colorama.Fore.RED` instead of `txtcolours.RED`!""" 230 | 231 | def txt_mod(num): 232 | return f'\x1b[{num}m' 233 | def custom_fore(r:int,g:int,b:int): 234 | return f'\x1b[38;2;{int(r)};{int(g)};{int(b)}m' 235 | def custom_back(r:int,g:int,b:int): 236 | return f'\x1b[48;2;{int(r)};{int(g)};{int(b)}m' 237 | 238 | END = txt_mod(0) 239 | BOLD = txt_mod(1) 240 | LIGHT = txt_mod(2) 241 | ITALIC = txt_mod(3) 242 | UNDERLINE = txt_mod(4) 243 | INVERTED = txt_mod(7) 244 | CROSSED = txt_mod(9) 245 | 246 | ALT_GREY, GREY, INVERTED_GREY = txt_mod(30), txt_mod(90), txt_mod(40) 247 | ALT_RED, RED, INVERTED_RED = txt_mod(31), txt_mod(91), txt_mod(41) 248 | ALT_GREEN, GREEN, INVERTED_GREEN = txt_mod(32), txt_mod(92), txt_mod(42) 249 | ALT_YELLOW, YELLOW, INVERTED_YELLOW = txt_mod(33), txt_mod(93), txt_mod(43) 250 | ALT_BLUE, BLUE, INVERTED_BLUE = txt_mod(34), txt_mod(94), txt_mod(44) 251 | ALT_PURPLE, PURPLE, INVERTED_PURPLE = txt_mod(35), txt_mod(95), txt_mod(45) 252 | ALT_CYAN, CYAN, INVERTED_CYAN = txt_mod(36), txt_mod(96), txt_mod(46) 253 | 254 | COLOURS = [RED, GREEN, YELLOW, BLUE, PURPLE, CYAN] 255 | ALT_COLOURS = [ALT_RED, ALT_GREEN, ALT_YELLOW, ALT_BLUE, ALT_PURPLE, ALT_CYAN] 256 | INVERTED_COLOURS = [INVERTED_RED, INVERTED_GREEN, INVERTED_YELLOW, INVERTED_BLUE, INVERTED_PURPLE, INVERTED_CYAN] 257 | ALL_COLOURS = COLOURS + ALT_COLOURS --------------------------------------------------------------------------------