├── pool ├── __init__.py ├── event.py ├── main.py ├── collisions.py ├── config.py ├── physics.py ├── graphics.py ├── table_sprites.py ├── cue.py ├── ball.py └── gamestate.py ├── tests ├── __init__.py ├── test_triangle_area.py ├── test_distance.py ├── test_rotation_matrix.py ├── test_ball_line_collision.py └── test_ball_ball_collision.py ├── .coveragerc ├── requirements.txt ├── .gitignore ├── test_requirements.txt ├── .travis.yml ├── run.sh ├── LICENSE └── README.md /pool/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit = tests/* 3 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | numpy 2 | pygame 3 | zope.event -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | .idea/ 3 | .coverage 4 | .cache/ -------------------------------------------------------------------------------- /test_requirements.txt: -------------------------------------------------------------------------------- 1 | pytest==3.0.7 2 | codeclimate-test-reporter 3 | pytest-cov 4 | -------------------------------------------------------------------------------- /tests/test_triangle_area.py: -------------------------------------------------------------------------------- 1 | from pool import physics 2 | 3 | class TestTriangleArea(): 4 | def test_triangle_area1(self): 5 | assert physics.triangle_area(1, 1, 0) == 0 6 | 7 | def test_triangle_area2(self): 8 | assert physics.triangle_area(3, 4, 5) == 0.5 * 3 * 4 9 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "3.5" 4 | - "3.6" 5 | install: 6 | - pip install -r requirements.txt -r test_requirements.txt 7 | script: 8 | - PYTHONPATH=./pool py.test --cov=. 9 | after_success: 10 | - codeclimate-test-reporter 11 | notifications: 12 | email: false 13 | -------------------------------------------------------------------------------- /tests/test_distance.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | from pool import physics 4 | 5 | 6 | class TestPointDistance(): 7 | def test_point_distance1(self): 8 | assert physics.point_distance(np.array([0, 0]), np.array([3, 4])) == 5 9 | 10 | def test_point_distance2(self): 11 | assert physics.point_distance( 12 | np.array([0, -10]), np.array([0, -10])) == 0 13 | 14 | def test_point_distance3(self): 15 | assert physics.point_distance( 16 | np.array([10, 0]), np.array([-10, 0])) == 20 17 | -------------------------------------------------------------------------------- /tests/test_rotation_matrix.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | 3 | import numpy as np 4 | 5 | from pool import physics 6 | 7 | class TestRotationMatrix(): 8 | def test_rotation_matrix1(self): 9 | for x, y, z in itertools.product(np.linspace(-1, 1, 5), repeat=3): 10 | if x != 0 or y != 0 or z != 0: 11 | assert np.all(physics.rotation_matrix( 12 | np.array([x, y, z]), 0) == np.identity(3)) 13 | 14 | def test_rotation_matrix2(self): 15 | for x, y, z in itertools.product(np.linspace(-1, 1, 5), repeat=3): 16 | if x != 0 or y != 0 or z != 0: 17 | assert np.all(np.round(physics.rotation_matrix( 18 | np.array([x, y, z]), 2 * np.pi), 10) == np.identity(3)) 19 | -------------------------------------------------------------------------------- /pool/event.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pygame 3 | 4 | 5 | class GameEvent(): 6 | def __init__(self, event_type, data): 7 | self.type = event_type 8 | self.data = data 9 | 10 | 11 | def set_allowed_events(): 12 | # only allow keypress events to avoid waisting cpu type on checking useless events 13 | pygame.event.set_allowed([pygame.KEYDOWN, pygame.QUIT]) 14 | 15 | def events(): 16 | closed = False 17 | quit = False 18 | 19 | for event in pygame.event.get(): 20 | if event.type == pygame.QUIT: 21 | closed = True 22 | if event.type == pygame.KEYDOWN: 23 | if event.key == pygame.K_ESCAPE: 24 | quit = True 25 | 26 | return {"quit_to_main_menu": quit, 27 | "closed": closed, 28 | "clicked": pygame.mouse.get_pressed()[0], 29 | "mouse_pos": np.array(pygame.mouse.get_pos())} 30 | -------------------------------------------------------------------------------- /run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | if !(command -v python3 > /dev/null 2>&1;) then 3 | echo python3 was not found on your system 4 | echo please check README.md for instructions 5 | else 6 | if !(python3 -c "import venv" &> /dev/null;) then 7 | echo no venv module was found in python3 8 | echo please check README.md for instructions 9 | elif !(python3 -c "import pip" &> /dev/null;) then 10 | echo no pip module was found in python3 11 | echo please check README.md for instructions 12 | else 13 | if [ -e pool/bin/activate ]; then 14 | source pool/bin/activate 15 | else 16 | python3 -m venv pool 17 | source pool/bin/activate 18 | echo installing venv packages 19 | python3 -m pip install -r requirements.txt 20 | echo Succesfully installed a virtual environment. 21 | fi 22 | python3 pool/main.py 23 | deactivate 24 | fi 25 | fi 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Max Kovalovs 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /pool/main.py: -------------------------------------------------------------------------------- 1 | import pygame 2 | 3 | import collisions 4 | import event 5 | import gamestate 6 | import graphics 7 | import config 8 | 9 | was_closed = False 10 | while not was_closed: 11 | game = gamestate.GameState() 12 | button_pressed = graphics.draw_main_menu(game) 13 | 14 | if button_pressed == config.play_game_button: 15 | game.start_pool() 16 | events = event.events() 17 | while not (events["closed"] or game.is_game_over or events["quit_to_main_menu"]): 18 | events = event.events() 19 | collisions.resolve_all_collisions(game.balls, game.holes, game.table_sides) 20 | game.redraw_all() 21 | 22 | if game.all_not_moving(): 23 | game.check_pool_rules() 24 | game.cue.make_visible(game.current_player) 25 | while not ( 26 | (events["closed"] or events["quit_to_main_menu"]) or game.is_game_over) and game.all_not_moving(): 27 | game.redraw_all() 28 | events = event.events() 29 | if game.cue.is_clicked(events): 30 | game.cue.cue_is_active(game, events) 31 | elif game.can_move_white_ball and game.white_ball.is_clicked(events): 32 | game.white_ball.is_active(game, game.is_behind_line_break()) 33 | was_closed = events["closed"] 34 | 35 | if button_pressed == config.exit_button: 36 | was_closed = True 37 | 38 | pygame.quit() 39 | -------------------------------------------------------------------------------- /pool/collisions.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | import random 3 | 4 | import zope.event 5 | 6 | import config 7 | import event 8 | import physics 9 | 10 | 11 | def resolve_all_collisions(balls, holes, table_sides): 12 | # destroys any circles that are in a hole 13 | for ball_hole_combination in itertools.product(balls, holes): 14 | if physics.distance_less_equal(ball_hole_combination[0].ball.pos, ball_hole_combination[1].pos, config.hole_radius): 15 | zope.event.notify(event.GameEvent("POTTED", ball_hole_combination[0])) 16 | 17 | # collides balls with the table where it is needed 18 | for line_ball_combination in itertools.product(table_sides, balls): 19 | if physics.line_ball_collision_check(line_ball_combination[0], line_ball_combination[1].ball): 20 | physics.collide_line_ball(line_ball_combination[0], line_ball_combination[1].ball) 21 | 22 | ball_list = balls.sprites() 23 | # ball list is shuffled to randomize ball collisions on the 1st break 24 | random.shuffle(ball_list) 25 | 26 | 27 | for ball_combination in itertools.combinations(ball_list, 2): 28 | if physics.ball_collision_check(ball_combination[0].ball, ball_combination[1].ball): 29 | physics.collide_balls(ball_combination[0].ball, ball_combination[1].ball) 30 | zope.event.notify(event.GameEvent("COLLISION", ball_combination)) 31 | 32 | 33 | def check_if_ball_touches_balls(target_ball_pos, target_ball_number, balls): 34 | touches_other_balls = False 35 | for ball in balls: 36 | if target_ball_number != ball.number and \ 37 | physics.distance_less_equal(ball.ball.pos, target_ball_pos, config.ball_radius * 2): 38 | touches_other_balls = True 39 | break 40 | return touches_other_balls 41 | -------------------------------------------------------------------------------- /tests/test_ball_line_collision.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | from pool import ball 4 | from pool import physics 5 | from pool import table_sprites 6 | from pool.config import ball_radius 7 | 8 | table_side_1 = table_sprites.TableSide([[0, 0], [10, 10]]) 9 | table_side_2 = table_sprites.TableSide([[0, 0], [10, 0]]) 10 | ball1 = ball.Ball() 11 | 12 | ball1.move_to((0, 0)) 13 | ball1.set_velocity((1, 1)) 14 | 15 | 16 | class TestBallLineCollision(): 17 | # reflection line tests 18 | # the lines should only reflect balls coming from one direction so the 19 | # balls wouldn't get stuck in the reflection lines 20 | def test_line_ball_collision_check1(self): 21 | ball1.move_to(table_side_1.middle + 22 | np.array([1., -1.]) * (ball_radius ** 0.5)) 23 | ball1.set_velocity([1, -1]) 24 | assert physics.line_ball_collision_check(table_side_1, ball1) 25 | 26 | def test_line_ball_collision_check2(self): 27 | ball1.move_to(table_side_1.middle + 28 | np.array([1., -1.]) * (ball_radius ** 0.5)) 29 | ball1.set_velocity([-1, 1]) 30 | assert not physics.line_ball_collision_check(table_side_1, ball1) 31 | 32 | def test_line_ball_collision_check3(self): 33 | ball1.move_to(table_side_1.middle + 1) 34 | ball1.set_velocity([1, -1]) 35 | assert physics.line_ball_collision_check(table_side_1, ball1) 36 | 37 | def test_line_ball_collision_check4(self): 38 | ball1.move_to(table_side_1.middle + ball_radius) 39 | ball1.set_velocity([-1, 1]) 40 | assert not physics.line_ball_collision_check(table_side_1, ball1) 41 | 42 | def test_line_ball_collision5(self): 43 | ball1.move_to([5, ball_radius]) 44 | ball1.set_velocity([1, -1]) 45 | assert physics.line_ball_collision_check(table_side_2, ball1) 46 | physics.collide_line_ball(table_side_2, ball1) 47 | assert np.all(np.around(ball1.velocity, 0) == [1., 1.]) 48 | 49 | def test_line_ball_collision6(self): 50 | ball1.move_to(table_side_1.middle + 51 | np.array([1., -1.]) * (ball_radius ** 0.5)) 52 | ball1.set_velocity([1, -1]) 53 | assert physics.line_ball_collision_check(table_side_1, ball1) 54 | physics.collide_line_ball(table_side_1, ball1) 55 | assert np.all(np.around(ball1.velocity, 0) == [-1., 1.]) 56 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
3 |
4 |
6 |
7 |
A pool game written entirely in python!
10 | 11 | 12 |  13 | 14 | 15 | ## Features 16 | * Realistic collisions based on a two-dimensional Newtonian model. 17 | * Simple configuration file (config.py) with many changeable options like ball size, ball colour, cue length/thickness and many more. 18 | * Algorithms which render ball sprites using rotation matrices. 19 | * Tests for collision functions and other math related functions. 20 | * A small and configurable game menu. 21 | 22 | ## Installing 23 | ### Dependencies 24 | The pool game requires python 3.5 with modules which are listed in `requirements.txt` . 25 | 26 | ### Installing on debian-based linux distributions 27 | Install python 3.5 with pip, venv and pygame dependencies 28 | ``` 29 | sudo apt-get build-dep python-pygame 30 | sudo apt-get install python-dev python3 python3-pip python3-venv 31 | ``` 32 | Then, clone the github code and run the game using run.sh, which will setup a virtual python environment with the aforementioned modules. 33 | ``` 34 | git clone git://github.com/max-kov/pool.git 35 | cd pool 36 | ./run.sh 37 | ``` 38 | If the pygame installation **fails**, it's most likely due to apt not having any URIs in sources.list file. To fix execute 39 | ``` 40 | sudo sed -i -- 's/#deb-src/deb-src/g' /etc/apt/sources.list && sudo sed -i -- 's/# deb-src/deb-src/g' /etc/apt/sources.list 41 | sudo apt-get update 42 | ``` 43 | and run the installation procedure again. 44 | 45 | ### Windows 46 | 47 | Download [python 3.5](https://www.python.org/downloads/release/python-353/) with [pip](https://docs.python.org/3/installing/index.html#pip-not-installed) then [add python to the path variable](https://superuser.com/a/143121) and run 48 | ``` 49 | python -m pip install -r requirements.txt 50 | ``` 51 | in the *administrator* cmd in the game folder to install the dependencies. Finally, start `main.py` to run the game. You might have to use `python3` instead of `python` depending if you have python2 installed. To check that you are using the right vesrion, write `python` in the console to see what version is used. 52 | 53 | ## Running the tests 54 | 55 | You can always see the results of the latest build [here](https://travis-ci.org/max-kov/pool). If you want to run the tests yourself, we will need extra modules. (On linux) Run 56 | ``` 57 | pip3 install -r test_requirements.txt 58 | ``` 59 | in the game folder to install the testing modules. To run the tests write 60 | ``` 61 | PYTHONPATH=./pool pytest tests/ 62 | ``` 63 | You can also check test coverage by executing 64 | ``` 65 | PYTHONPATH=./pool pytest --cov=. tests/ 66 | ``` 67 | That will analyse which files and which lines of code were executed by the tests.`.coveragerc` will prevent the module from analysing test files as well. 68 | 69 | ## Built With 70 | 71 | * [Python 3.5](https://www.python.org/) 72 | * [Pygame](http://www.pygame.org/) - 2d graphics library 73 | * [Numpy](http://www.numpy.org/) - Scientific computing library, used here for vector opertations 74 | * [Travis CI](https://travis-ci.org/max-kov/pool) and [CodeClimate](https://codeclimate.com/github/max-kov/pool) - Testing and code analysis 75 | -------------------------------------------------------------------------------- /pool/config.py: -------------------------------------------------------------------------------- 1 | import math 2 | 3 | import numpy as np 4 | import pygame 5 | 6 | 7 | # fonts need to be initialised before using 8 | def get_default_font(size): 9 | font_defualt = pygame.font.get_default_font() 10 | return pygame.font.Font(font_defualt, size) 11 | 12 | 13 | def set_max_resolution(): 14 | infoObject = pygame.display.Info() 15 | global resolution 16 | global white_ball_initial_pos 17 | resolution = np.array([infoObject.current_w, infoObject.current_h]) 18 | white_ball_initial_pos = (resolution + [table_margin + hole_radius, 0]) * [0.25, 0.5] 19 | 20 | # window settings 21 | fullscreen = False 22 | # fullscreen resolution can only be known after initialising the screen 23 | if not fullscreen: 24 | resolution = np.array([1000, 500]) 25 | window_caption = "Pool" 26 | fps_limit = 60 27 | 28 | # table settings 29 | table_margin = 40 30 | table_side_color = (200, 200, 0) 31 | table_color = (0, 100, 0) 32 | separation_line_color = (200, 200, 200) 33 | hole_radius = 22 34 | middle_hole_offset = np.array([[-hole_radius * 2, hole_radius], [-hole_radius, 0], 35 | [hole_radius, 0], [hole_radius * 2, hole_radius]]) 36 | side_hole_offset = np.array([ 37 | [- 2 * math.cos(math.radians(45)) * hole_radius - hole_radius, hole_radius], 38 | [- math.cos(math.radians(45)) * hole_radius, - 39 | math.cos(math.radians(45)) * hole_radius], 40 | [math.cos(math.radians(45)) * hole_radius, 41 | math.cos(math.radians(45)) * hole_radius], 42 | [- hole_radius, 2 * math.cos(math.radians(45)) * hole_radius + hole_radius] 43 | ]) 44 | 45 | # cue settings 46 | player1_cue_color = (200, 100, 0) 47 | player2_cue_color = (0, 100, 200) 48 | cue_hit_power = 3 49 | cue_length = 250 50 | cue_thickness = 4 51 | cue_max_displacement = 100 52 | # safe displacement is the length the cue stick can be pulled before 53 | # causing the ball to move 54 | cue_safe_displacement = 1 55 | aiming_line_length = 14 56 | 57 | # ball settings 58 | total_ball_num = 16 59 | ball_radius = 14 60 | ball_mass = 14 61 | speed_angle_threshold = 0.09 62 | visible_angle_threshold = 0.05 63 | ball_colors = [ 64 | (255, 255, 255), 65 | (0, 200, 200), 66 | (0, 0, 200), 67 | (150, 0, 0), 68 | (200, 0, 200), 69 | (200, 0, 0), 70 | (50, 0, 0), 71 | (100, 0, 0), 72 | (0, 0, 0), 73 | (0, 200, 200), 74 | (0, 0, 200), 75 | (150, 0, 0), 76 | (200, 0, 200), 77 | (200, 0, 0), 78 | (50, 0, 0), 79 | (100, 0, 0) 80 | ] 81 | ball_stripe_thickness = 5 82 | ball_stripe_point_num = 25 83 | # where the balls will be placed at the start 84 | # relative to screen resolution 85 | ball_starting_place_ratio = [0.75, 0.5] 86 | # in fullscreen mode the resolution is only available after initialising the screen 87 | # and if the screen wasn't initialised the resolution variable won't exist 88 | if 'resolution' in locals(): 89 | white_ball_initial_pos = (resolution + [table_margin + hole_radius, 0]) * [0.25, 0.5] 90 | ball_label_text_size = 10 91 | 92 | # physics 93 | # if the velocity of the ball is less then 94 | # friction threshold then it is stopped 95 | friction_threshold = 0.06 96 | friction_coeff = 0.99 97 | # 1 - perfectly elastic ball collisions 98 | # 0 - perfectly inelastic collisions 99 | ball_coeff_of_restitution = 0.9 100 | table_coeff_of_restitution = 0.9 101 | 102 | # menu 103 | menu_text_color = (255, 255, 255) 104 | menu_text_selected_color = (0, 0, 255) 105 | menu_title_text = "Pool" 106 | menu_buttons = ["Play Pool", "Exit"] 107 | menu_margin = 20 108 | menu_spacing = 10 109 | menu_title_font_size = 40 110 | menu_option_font_size = 20 111 | exit_button = 2 112 | play_game_button = 1 113 | 114 | # in-game ball target variables 115 | player1_target_text = 'P1 balls - ' 116 | player2_target_text = 'P2 balls - ' 117 | target_ball_spacing = 3 118 | player1_turn_label = "Player 1 turn" 119 | player2_turn_label = "Player 2 turn" 120 | penalty_indication_text = " (click on the ball to move it)" 121 | game_over_label_font_size = 40 122 | -------------------------------------------------------------------------------- /tests/test_ball_ball_collision.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | from pool import ball 4 | from pool import physics 5 | from pool.config import ball_radius 6 | 7 | ball1 = ball.Ball() 8 | ball2 = ball.Ball() 9 | 10 | ball1.move_to((0, 0)) 11 | ball1.set_velocity((1, 1)) 12 | 13 | fortyfive_degree_position = np.array( 14 | [np.sin(np.pi / 4), np.cos(np.pi / 4)]) * ball_radius * 2 15 | 16 | 17 | class TestBallBallCollision(): 18 | # distance tests will check if the ball distance collision check 19 | # function is working properly 20 | 21 | def test_distance1(self): 22 | ball2.move_to((ball_radius * 2, 0)) 23 | ball2.set_velocity((0, 0)) 24 | assert physics.ball_collision_check(ball1, ball2) 25 | 26 | def test_distance2(self): 27 | ball2.move_to((0, ball_radius * 2 + 1)) 28 | ball2.set_velocity((0, 0)) 29 | assert not physics.ball_collision_check(ball1, ball2) 30 | 31 | def test_distance3(self): 32 | ball2.move_to((1, ball_radius * 2)) 33 | ball2.set_velocity((0, 0)) 34 | assert not physics.ball_collision_check(ball1, ball2) 35 | 36 | def test_distance4(self): 37 | ball2.move_to((ball_radius * 2 - 1, 0)) 38 | ball2.set_velocity((0, 0)) 39 | assert physics.ball_collision_check(ball1, ball2) 40 | 41 | def test_distance5(self): 42 | ball2.move_to((1, 0)) 43 | ball2.set_velocity((0, 0)) 44 | assert physics.ball_collision_check(ball1, ball2) 45 | 46 | def test_distance6(self): 47 | ball2.move_to(fortyfive_degree_position) 48 | ball2.set_velocity((0, 0)) 49 | assert physics.ball_collision_check(ball1, ball2) 50 | 51 | # these will check that any balls that are moving away from each other 52 | # will not collide 53 | def test_movement1(self): 54 | ball1.set_velocity((1, 1)) 55 | 56 | ball2.move_to((-ball_radius, 0)) 57 | ball2.set_velocity((0, 0)) 58 | assert not physics.ball_collision_check(ball1, ball2) 59 | 60 | def test_movement2(self): 61 | ball1.set_velocity((1, 1)) 62 | 63 | ball2.move_to(fortyfive_degree_position) 64 | ball2.set_velocity((1, 1)) 65 | assert not physics.ball_collision_check(ball1, ball2) 66 | 67 | def test_movement3(self): 68 | ball1.set_velocity((1, 1)) 69 | 70 | ball2.move_to(fortyfive_degree_position) 71 | ball2.set_velocity((0.9, 1)) 72 | assert physics.ball_collision_check(ball1, ball2) 73 | 74 | def test_movement4(self): 75 | ball1.set_velocity((1, 1)) 76 | 77 | ball2.move_to(-fortyfive_degree_position) 78 | ball2.set_velocity((1, 1)) 79 | assert not physics.ball_collision_check(ball1, ball2) 80 | 81 | def test_movement5(self): 82 | ball1.set_velocity((1, 1)) 83 | 84 | ball2.move_to(-fortyfive_degree_position) 85 | ball2.set_velocity((1.1, 1)) 86 | assert physics.ball_collision_check(ball1, ball2) 87 | 88 | def test_movement6(self): 89 | ball1.set_velocity((0, 1)) 90 | 91 | ball2.move_to((ball_radius * 2, 0)) 92 | ball2.set_velocity((0, 0)) 93 | assert not physics.ball_collision_check(ball1, ball2) 94 | 95 | def test_movement7(self): 96 | ball1.set_velocity((0, 1)) 97 | 98 | ball2.move_to((0, ball_radius * 2)) 99 | ball2.set_velocity((200000000, 0)) 100 | assert physics.ball_collision_check(ball1, ball2) 101 | 102 | def test_movement8(self): 103 | ball1.set_velocity((0, 1)) 104 | 105 | ball2.move_to((0, ball_radius * 2)) 106 | ball2.set_velocity((200000000, 0.9)) 107 | assert physics.ball_collision_check(ball1, ball2) 108 | 109 | def test_movement9(self): 110 | ball1.set_velocity((0, 1)) 111 | 112 | ball2.move_to((0, ball_radius * 2)) 113 | ball2.set_velocity((200000000, 1)) 114 | assert not physics.ball_collision_check(ball1, ball2) 115 | 116 | def test_movement10(self): 117 | ball1.set_velocity((0, 1)) 118 | 119 | ball2.move_to((0, ball_radius * 2)) 120 | ball2.set_velocity((200000000, -200000000000)) 121 | assert physics.ball_collision_check(ball1, ball2) 122 | 123 | def test_movement11(self): 124 | # stationary balls do not collide to conserve unnecessary computations 125 | ball1.set_velocity((0, 0)) 126 | 127 | ball2.move_to((0, ball_radius * 2)) 128 | ball2.set_velocity((0, 0)) 129 | assert not physics.ball_collision_check(ball1, ball2) 130 | -------------------------------------------------------------------------------- /pool/physics.py: -------------------------------------------------------------------------------- 1 | import math 2 | 3 | import numpy as np 4 | 5 | import config 6 | 7 | 8 | def point_distance(p1, p2): 9 | dist_diff = p1 - p2 10 | return np.hypot(*dist_diff) 11 | 12 | 13 | def distance_less_equal(p1, p2, dist): 14 | # does distance comparisons without calculating square roots 15 | dist_diff = p1 - p2 16 | return (dist_diff[0] ** 2 + dist_diff[1] ** 2) <= dist ** 2 17 | 18 | 19 | def ball_collision_check(ball1, ball2): 20 | # distance check followed by checking if either of the balls are moving 21 | # followed by vector projection check, to see if both are moving towards 22 | # each other 23 | return distance_less_equal(ball1.pos, ball2.pos, 2 * config.ball_radius) and \ 24 | np.count_nonzero(np.concatenate((ball1.velocity, ball2.velocity))) > 0 and \ 25 | np.dot(ball2.pos - ball1.pos, ball1.velocity - ball2.velocity) > 0 26 | 27 | 28 | def collide_balls(ball1, ball2): 29 | point_diff = ball2.pos - ball1.pos 30 | dist = point_distance(ball1.pos, ball2.pos) 31 | # normalising circle distance difference vector 32 | collision = point_diff / dist 33 | # projecting balls velocity ONTO difference vector 34 | ball1_dot = np.dot(ball1.velocity, collision) 35 | ball2_dot = np.dot(ball2.velocity, collision) 36 | # since the masses of the balls are the same, the velocity will just switch 37 | ball1.velocity += (ball2_dot - ball1_dot) * collision * 0.5*(1+config.ball_coeff_of_restitution) 38 | ball2.velocity += (ball1_dot - ball2_dot) * collision * 0.5*(1+config.ball_coeff_of_restitution) 39 | 40 | 41 | def triangle_area(side1, side2, side3): 42 | # used to determine if the user is clicking on the cue stick 43 | # herons formula 44 | half_perimetre = abs((side1 + side2 + side3) * 0.5) 45 | return math.sqrt(half_perimetre * (half_perimetre - abs(side1)) * (half_perimetre - abs(side2)) * ( 46 | half_perimetre - abs(side3))) 47 | 48 | 49 | def rotation_matrix(axis, theta): 50 | # Return the rotation matrix associated with counterclockwise rotation about 51 | # the given axis by theta radians. 52 | axis = np.asarray(axis) 53 | axis = axis / math.sqrt(np.dot(axis, axis)) 54 | a = math.cos(theta / 2.0) 55 | b, c, d = -axis * math.sin(theta / 2.0) 56 | aa, bb, cc, dd = a * a, b * b, c * c, d * d 57 | bc, ad, ac, ab, bd, cd = b * c, a * d, a * c, a * b, b * d, c * d 58 | return np.array([[aa + bb - cc - dd, 2 * (bc + ad), 2 * (bd - ac)], 59 | [2 * (bc - ad), aa + cc - bb - dd, 2 * (cd + ab)], 60 | [2 * (bd + ac), 2 * (cd - ab), aa + dd - bb - cc]]) 61 | 62 | 63 | def line_ball_collision_check(line, ball): 64 | # checks if the ball is half the line length from the line middle 65 | if distance_less_equal(line.middle, ball.pos, line.length / 2 + config.ball_radius): 66 | # displacement vector from the first point to the ball 67 | displacement_to_ball = ball.pos - line.line[0] 68 | # displacement vector from the first point to the second point on the 69 | # line 70 | displacement_to_second_point = line.line[1] - line.line[0] 71 | normalised_point_diff_vector = displacement_to_second_point / \ 72 | np.hypot(*(displacement_to_second_point)) 73 | # distance from the first point on the line to the perpendicular 74 | # projection point from the ball 75 | projected_distance = np.dot(normalised_point_diff_vector, displacement_to_ball) 76 | # closest point on the line to the ball 77 | closest_line_point = projected_distance * normalised_point_diff_vector 78 | perpendicular_vector = np.array( 79 | [-normalised_point_diff_vector[1], normalised_point_diff_vector[0]]) 80 | # checking if closest point on the line is actually on the line (which is not always the case when projecting) 81 | # then checking if the distance from that point to the ball is less than the balls radius and finally 82 | # checking if the ball is moving towards the line with the dot product 83 | return -config.ball_radius / 3 <= projected_distance <= \ 84 | np.hypot(*(displacement_to_second_point)) + config.ball_radius / 3 and \ 85 | np.hypot(*(closest_line_point - ball.pos + line.line[0])) <= \ 86 | config.ball_radius and np.dot( 87 | perpendicular_vector, ball.velocity) <= 0 88 | 89 | 90 | def collide_line_ball(line, ball): 91 | displacement_to_second_point = line.line[1] - line.line[0] 92 | normalised_point_diff_vector = displacement_to_second_point / \ 93 | np.hypot(*(displacement_to_second_point)) 94 | perpendicular_vector = np.array( 95 | [-normalised_point_diff_vector[1], normalised_point_diff_vector[0]]) 96 | ball.velocity -= 2 * np.dot(perpendicular_vector,ball.velocity) * \ 97 | perpendicular_vector * 0.5*(1+config.table_coeff_of_restitution) 98 | -------------------------------------------------------------------------------- /pool/graphics.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pygame 3 | 4 | import config 5 | import event 6 | 7 | 8 | class Canvas: 9 | def __init__(self): 10 | if config.fullscreen: 11 | config.set_max_resolution() 12 | self.surface = pygame.display.set_mode(config.resolution, pygame.FULLSCREEN) 13 | else: 14 | self.surface = pygame.display.set_mode(config.resolution) 15 | self.background = pygame.Surface(self.surface.get_size()) 16 | self.background = self.background.convert() 17 | self.background.fill(config.table_color) 18 | self.surface.blit(self.background, (0, 0)) 19 | 20 | 21 | def add_separation_line(canvas): 22 | # white ball separation line 23 | pygame.draw.line(canvas.background, config.separation_line_color, (config.white_ball_initial_pos[0], 0), 24 | (config.white_ball_initial_pos[0], config.resolution[1])) 25 | 26 | 27 | def create_buttons(text, text_font, text_color_normal, text_color_on_hover): 28 | # this function generates button objects using button text, button font, normal colour and color of the text when 29 | # the mouse is hovering over the button 30 | 31 | button_size = np.array([text_font[num].size(text[num]) for num in range(len(text))]) 32 | # generating button objects 33 | buttons = [ 34 | # text when mouse is outside the button range 35 | [text_font[num].render(text[num], False, text_color_normal[num]), 36 | # text when mouse is inside the button range 37 | text_font[num].render(text[num], False, text_color_on_hover[num])] 38 | for num in range(len(text))] 39 | 40 | screen_mid = config.resolution[0] / 2 41 | change_in_y = (config.resolution[1] - 42 | config.menu_margin * 2) / (len(buttons)) 43 | screen_button_middles = np.stack((np.repeat([screen_mid], len(buttons)), 44 | np.arange(len(buttons)) * change_in_y), axis=1) 45 | 46 | text_starting_place = screen_button_middles + [-0.5, 0.5] * button_size 47 | text_ending_place = text_starting_place + button_size 48 | 49 | return buttons, button_size, text_starting_place, text_ending_place 50 | 51 | 52 | def draw_main_menu(game_state): 53 | buttons, button_size, text_starting_place, text_ending_place = create_buttons( 54 | [config.menu_title_text] + config.menu_buttons, 55 | [config.get_default_font(config.menu_title_font_size)] + [ 56 | config.get_default_font(config.menu_option_font_size)] * 3, 57 | [config.menu_text_color] * 4, 58 | [config.menu_text_color] + [config.menu_text_selected_color] * 3) 59 | draw_rects(button_size, buttons, game_state, text_starting_place, emit=[0]) 60 | button_clicked = iterate_until_button_press(buttons, game_state, text_ending_place, 61 | text_starting_place) 62 | 63 | return button_clicked 64 | 65 | 66 | def iterate_until_button_press(buttons, game_state, text_ending_place, text_starting_place): 67 | # while a button was not clicked this method checks if mouse is in the button and if it is 68 | # changes its colour 69 | button_clicked = 0 70 | while button_clicked == 0: 71 | pygame.display.update() 72 | user_events = event.events() 73 | # the first button is the title which is unclickable, thus iterating from 1 to len(buttons) 74 | for num in range(1, len(buttons)): 75 | if np.all((np.less(text_starting_place[num] - config.menu_spacing, user_events["mouse_pos"]), 76 | np.greater(text_ending_place[num] + config.menu_spacing, user_events["mouse_pos"]))): 77 | if user_events["clicked"]: 78 | button_clicked = num 79 | else: 80 | game_state.canvas.surface.blit( 81 | buttons[num][1], text_starting_place[num]) 82 | else: 83 | game_state.canvas.surface.blit( 84 | buttons[num][0], text_starting_place[num]) 85 | if user_events["closed"] or user_events["quit_to_main_menu"]: 86 | button_clicked = len(buttons)-1 87 | return button_clicked 88 | 89 | 90 | def draw_rects(button_size, buttons, game_state, text_starting_place, emit=list()): 91 | # drawing a rectangle around the button text 92 | for num in range(len(buttons)): 93 | game_state.canvas.surface.blit( 94 | buttons[num][0], text_starting_place[num]) 95 | # emit contains indexes of buttons which do not need a rectangle around them 96 | if not num in emit: 97 | pygame.draw.rect(game_state.canvas.surface, config.menu_text_color, 98 | np.concatenate((text_starting_place[num] - 99 | config.menu_spacing, button_size[num] + 100 | config.menu_spacing * 2)), 1) 101 | -------------------------------------------------------------------------------- /pool/table_sprites.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pygame 3 | 4 | import config 5 | import gamestate 6 | 7 | 8 | class Hole(pygame.sprite.Sprite): 9 | def __init__(self, x, y): 10 | pygame.sprite.Sprite.__init__(self) 11 | self.image = pygame.Surface( 12 | (2 * config.hole_radius, 2 * config.hole_radius)) 13 | # color which will be ignored 14 | self.image.fill((200, 200, 200)) 15 | self.image.set_colorkey((200, 200, 200)) 16 | 17 | pygame.draw.circle(self.image, (0, 0, 0), 18 | (config.hole_radius, config.hole_radius), config.hole_radius, 0) 19 | self.rect = self.image.get_rect() 20 | self.rect.center = (x, y) 21 | self.pos = np.array([x, y]) 22 | 23 | 24 | # this class holds properties of a table side line, but doesn't actually 25 | # draw it 26 | class TableSide(): 27 | def __init__(self, line): 28 | self.line = np.array(line) 29 | self.middle = (self.line[0] + self.line[1]) / 2 30 | self.size = np.round(np.abs(self.line[0] - self.line[1])) 31 | self.length = np.hypot(*self.size) 32 | if np.count_nonzero(self.size) != 2: 33 | # line is perpendicular to y or x axis 34 | if self.size[0] == 0: 35 | self.size[0] += 1 36 | else: 37 | self.size[1] += 1 38 | 39 | 40 | # draws the yellow part of the table 41 | class TableColoring(pygame.sprite.Sprite): 42 | def __init__(self, table_size, color, table_points): 43 | pygame.sprite.Sprite.__init__(self) 44 | self.points = table_points 45 | self.image = pygame.Surface(table_size) 46 | self.image.fill(color) 47 | color_key = (200, 200, 200) 48 | self.image.set_colorkey(color_key) 49 | pygame.draw.polygon(self.image, color_key, table_points) 50 | self.rect = self.image.get_rect() 51 | self.rect.topleft = (0, 0) 52 | self.font = config.get_default_font(config.ball_radius) 53 | # generates text at the bottom of the table 54 | self.target_ball_text = [self.font.render(config.player1_target_text, False, config.player1_cue_color), 55 | self.font.render(config.player2_target_text, False, config.player2_cue_color)] 56 | 57 | def redraw(self): 58 | self.image.fill(config.table_side_color) 59 | color_key = (200, 200, 200) 60 | self.image.set_colorkey(color_key) 61 | pygame.draw.polygon(self.image, color_key, self.points) 62 | 63 | def update(self, game_state): 64 | self.redraw() 65 | self.generate_top_left_label(game_state) 66 | self.generate_target_balls(game_state) 67 | 68 | def generate_target_balls(self, game_state): 69 | # draws the target balls for each players 70 | if game_state.ball_assignment is not None: 71 | start_x = np.array([config.table_margin + config.hole_radius * 3, 72 | config.resolution[0] / 2 + config.hole_radius * 3]) 73 | start_y = config.resolution[1] - config.table_margin - self.font.size(config.player1_target_text)[1] / 2 74 | # the text needs to be moved a bit lower to keep it aligned 75 | self.image.blit(self.target_ball_text[0], [start_x[0], start_y + config.ball_radius / 2]) 76 | self.image.blit(self.target_ball_text[1], [start_x[1], start_y + config.ball_radius / 2]) 77 | start_x += self.font.size(config.player2_target_text)[0] 78 | for ball in game_state.balls: 79 | do_draw = ball.number != 0 and ball.number != 8 80 | 81 | # draw to player holds the players which the balls will be added to 82 | draw_to_player = [] 83 | 84 | # sorts the balls into their places 85 | if do_draw: 86 | if game_state.ball_assignment[gamestate.Player.Player1] == ball.ball_type: 87 | draw_to_player.append(1) 88 | else: 89 | draw_to_player.append(2) 90 | 91 | if ball.number == 8: 92 | if game_state.potting_8ball[gamestate.Player.Player1]: 93 | draw_to_player.append(1) 94 | if game_state.potting_8ball[gamestate.Player.Player2]: 95 | draw_to_player.append(2) 96 | 97 | # draws the balls 98 | for player in draw_to_player: 99 | # player-1 because lists start with 0 100 | ball.create_image(self.image, (start_x[player - 1], start_y)) 101 | start_x[player - 1] += config.ball_radius * 2 + config.target_ball_spacing 102 | 103 | def generate_top_left_label(self, game_state): 104 | # generates the top left label (which players turn is it and if he can move the ball) 105 | top_left_text = "" 106 | if game_state.can_move_white_ball: 107 | top_left_text += config.penalty_indication_text 108 | if game_state.current_player.value == 1: 109 | top_left_rendered_text = self.font.render(config.player1_turn_label + top_left_text, 110 | False, config.player1_cue_color) 111 | else: 112 | top_left_rendered_text = self.font.render(config.player2_turn_label + top_left_text, 113 | False, config.player2_cue_color) 114 | text_pos = [config.table_margin + config.hole_radius * 3, 115 | config.table_margin - self.font.size(top_left_text)[1] / 2] 116 | self.image.blit(top_left_rendered_text, text_pos) 117 | -------------------------------------------------------------------------------- /pool/cue.py: -------------------------------------------------------------------------------- 1 | import math 2 | 3 | import numpy as np 4 | import pygame 5 | 6 | import config 7 | import event 8 | import gamestate 9 | import physics 10 | 11 | 12 | class Cue(pygame.sprite.Sprite): 13 | def __init__(self, target): 14 | pygame.sprite.Sprite.__init__(self) 15 | self.angle = 0 16 | self.color = config.player1_cue_color 17 | self.target_ball = target 18 | self.visible = False 19 | self.displacement = config.ball_radius 20 | self.sprite_size = np.repeat( 21 | [config.cue_length + config.cue_max_displacement], 2) 22 | self.clear_canvas() 23 | 24 | def clear_canvas(self): 25 | # create empty surface as a placeholder for the cue 26 | self.image = pygame.Surface(2 * self.sprite_size) 27 | self.image.fill((200, 200, 200)) 28 | self.image.set_colorkey((200, 200, 200)) 29 | self.rect = self.image.get_rect() 30 | self.rect.center = self.target_ball.ball.pos.tolist() 31 | 32 | def update(self, *args): 33 | if self.visible: 34 | self.image = pygame.Surface(2 * self.sprite_size) 35 | # color which will be ignored 36 | self.image.fill((200, 200, 200)) 37 | self.image.set_colorkey((200, 200, 200)) 38 | 39 | sin_cos = np.array([math.sin(self.angle), math.cos(self.angle)]) 40 | initial_coords = np.array([math.sin(self.angle + 0.5 * math.pi), math.cos(self.angle + 41 | 0.5 * math.pi)]) * config.cue_thickness 42 | coord_diff = sin_cos * config.cue_length 43 | rectangle_points = np.array((initial_coords, -initial_coords, 44 | -initial_coords + coord_diff, initial_coords + coord_diff)) 45 | rectangle_points_from_circle = rectangle_points + self.displacement * sin_cos 46 | pygame.draw.polygon(self.image, self.color, 47 | rectangle_points_from_circle + self.sprite_size) 48 | 49 | self.points_on_screen = rectangle_points_from_circle + self.target_ball.ball.pos 50 | self.rect = self.image.get_rect() 51 | self.rect.center = self.target_ball.ball.pos.tolist() 52 | else: 53 | self.clear_canvas() 54 | 55 | def is_point_in_cue(self, point): 56 | # this algorithm splits up the rectangle into 4 triangles using the point provided 57 | # if the point provided is inside the triangle the sum of triangle 58 | # areas should be equal to that of the rectangle 59 | rect_sides = [config.cue_thickness * 2, config.cue_length] * 2 60 | triangle_sides = np.apply_along_axis( 61 | physics.point_distance, 1, self.points_on_screen, point) 62 | calc_area = np.vectorize(physics.triangle_area) 63 | triangle_areas = np.sum( 64 | calc_area(triangle_sides, np.roll(triangle_sides, -1), rect_sides)) 65 | rect_area = rect_sides[0] * rect_sides[1] 66 | # +1 to prevent rounding errors 67 | return rect_area + 1 >= triangle_areas 68 | 69 | def update_cue_displacement(self, mouse_pos, initial_mouse_dist): 70 | displacement = physics.point_distance( 71 | mouse_pos, self.target_ball.ball.pos) - initial_mouse_dist + config.ball_radius 72 | if displacement > config.cue_max_displacement: 73 | self.displacement = config.cue_max_displacement 74 | elif displacement < config.ball_radius: 75 | self.displacement = config.ball_radius 76 | else: 77 | self.displacement = displacement 78 | 79 | def draw_lines(self, game_state, target_ball, angle, color): 80 | cur_pos = np.copy(target_ball.ball.pos) 81 | diff = np.array([math.sin(angle), math.cos(angle)]) 82 | 83 | while config.resolution[1] > cur_pos[1] > 0 and config.resolution[0] > cur_pos[0] > 0: 84 | cur_pos += config.aiming_line_length * diff * 2 85 | pygame.draw.line(game_state.canvas.surface, color, cur_pos, 86 | (cur_pos + config.aiming_line_length * diff)) 87 | 88 | def is_clicked(self, events): 89 | return events["clicked"] and self.is_point_in_cue(events["mouse_pos"]) 90 | 91 | def make_visible(self, current_player): 92 | if current_player == gamestate.Player.Player1: 93 | self.color = config.player1_cue_color 94 | else: 95 | self.color = config.player2_cue_color 96 | self.visible = True 97 | self.update() 98 | 99 | def make_invisible(self): 100 | self.visible = False 101 | 102 | def cue_is_active(self, game_state, events): 103 | initial_mouse_pos = events["mouse_pos"] 104 | initial_mouse_dist = physics.point_distance( 105 | initial_mouse_pos, self.target_ball.ball.pos) 106 | 107 | while events["clicked"]: 108 | events = event.events() 109 | self.update_cue(game_state, initial_mouse_dist, events) 110 | # undraw leftover aiming lines 111 | self.draw_lines(game_state, self.target_ball, self.angle + 112 | math.pi, config.table_color) 113 | 114 | if self.displacement > config.ball_radius+config.cue_safe_displacement: 115 | self.ball_hit() 116 | 117 | def ball_hit(self): 118 | new_velocity = -(self.displacement - config.ball_radius - config.cue_safe_displacement) * \ 119 | config.cue_hit_power * np.array([math.sin(self.angle), math.cos(self.angle)]) 120 | change_in_disp = np.hypot(*new_velocity) * 0.1 121 | while self.displacement - change_in_disp > config.ball_radius: 122 | self.displacement -= change_in_disp 123 | self.update() 124 | pygame.display.flip() 125 | self.target_ball.ball.apply_force(new_velocity) 126 | self.displacement = config.ball_radius 127 | self.visible = False 128 | 129 | def update_cue(self, game_state, initial_mouse_dist, events): 130 | # updates cue position 131 | current_mouse_pos = events["mouse_pos"] 132 | displacement_from_ball_to_mouse = self.target_ball.ball.pos - current_mouse_pos 133 | self.update_cue_displacement(current_mouse_pos, initial_mouse_dist) 134 | prev_angle = self.angle 135 | # hack to avoid div by zero 136 | if not displacement_from_ball_to_mouse[0] == 0: 137 | self.angle = 0.5 * math.pi - math.atan( 138 | displacement_from_ball_to_mouse[1] / displacement_from_ball_to_mouse[0]) 139 | if displacement_from_ball_to_mouse[0] > 0: 140 | self.angle -= math.pi 141 | 142 | game_state.redraw_all(update=False) 143 | self.draw_lines(game_state, self.target_ball, prev_angle + 144 | math.pi, config.table_color) 145 | self.draw_lines(game_state, self.target_ball, self.angle + 146 | math.pi, (255, 255, 255)) 147 | pygame.display.flip() 148 | -------------------------------------------------------------------------------- /pool/ball.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | import math 3 | from enum import Enum 4 | 5 | import numpy as np 6 | import pygame 7 | 8 | import collisions 9 | import config 10 | import event 11 | import physics 12 | 13 | 14 | class Ball(): 15 | def __init__(self): 16 | self.pos = np.zeros(2, dtype=float) 17 | self.velocity = np.zeros(2, dtype=float) 18 | 19 | def apply_force(self, force, time=1): 20 | # f = ma, v = u + at -> v = u + (f/m)*t 21 | self.velocity += (force / config.ball_mass) * time 22 | 23 | def set_velocity(self, new_velocity): 24 | self.velocity = np.array(new_velocity, dtype=float) 25 | 26 | def move_to(self, pos): 27 | self.pos = np.array(pos, dtype=float) 28 | 29 | def update(self, *args): 30 | self.velocity *= config.friction_coeff 31 | self.pos += self.velocity 32 | 33 | if np.hypot(*self.velocity) < config.friction_threshold: 34 | self.velocity = np.zeros(2) 35 | 36 | 37 | class BallType(Enum): 38 | Striped = "striped" 39 | Solid = "solid" 40 | 41 | 42 | class StripedBall(): 43 | def __init__(self): 44 | # every point is a 3d coordinate on the ball 45 | # a circle will be drawn on the point if its Z component is >0 (is 46 | # visible) 47 | point_num = config.ball_stripe_point_num 48 | self.stripe_circle = config.ball_radius * np.column_stack((np.cos(np.linspace(0, 2 * np.pi, point_num)), 49 | np.sin(np.linspace( 50 | 0, 2 * np.pi, point_num)), 51 | np.zeros(point_num))) 52 | 53 | def update_stripe(self, transformation_matrix): 54 | for i, stripe in enumerate(self.stripe_circle): 55 | self.stripe_circle[i] = np.matmul( 56 | stripe, transformation_matrix) 57 | 58 | def draw_stripe(self, sprite): 59 | for num, point in enumerate(self.stripe_circle[:-1]): 60 | if point[2] >= -1: 61 | pygame.draw.line(sprite, (255, 255, 255), config.ball_radius + point[:2], 62 | config.ball_radius + self.stripe_circle[num + 1][:2], config.ball_stripe_thickness) 63 | 64 | 65 | class SolidBall(): 66 | def __init__(self): 67 | pass 68 | 69 | 70 | class BallSprite(pygame.sprite.Sprite): 71 | def __init__(self, ball_number): 72 | self.number = ball_number 73 | self.color = config.ball_colors[ball_number] 74 | if ball_number <= 8: 75 | self.ball_type = BallType.Solid 76 | self.ball_stripe = SolidBall() 77 | else: 78 | self.ball_type = BallType.Striped 79 | self.ball_stripe = StripedBall() 80 | self.ball = Ball() 81 | pygame.sprite.Sprite.__init__(self) 82 | # initial location of the white circle and number on the ball, a.k.a 83 | # ball label 84 | self.label_offset = np.array([0, 0, config.ball_radius]) 85 | self.label_size = config.ball_radius // 2 86 | font_obj = config.get_default_font(config.ball_label_text_size) 87 | self.text = font_obj.render(str(ball_number), False, (0, 0, 0)) 88 | self.text_length = np.array(font_obj.size(str(ball_number))) 89 | self.update_sprite() 90 | self.update() 91 | self.top_left = self.ball.pos - config.ball_radius 92 | self.rect.center = self.ball.pos.tolist() 93 | 94 | def update(self, *args): 95 | if np.hypot(*self.ball.velocity) != 0: 96 | # updates label circle and number offset 97 | perpendicular_velocity = -np.cross(self.ball.velocity, [0, 0, 1]) 98 | # angle formula is angle=((ballspeed*2)/(pi*r*2))*2 99 | rotation_angle = -np.hypot( 100 | *(self.ball.velocity)) * 2 / (config.ball_radius * np.pi) 101 | transformation_matrix = physics.rotation_matrix( 102 | perpendicular_velocity, rotation_angle) 103 | self.label_offset = np.matmul( 104 | self.label_offset, transformation_matrix) 105 | if self.ball_type == BallType.Striped: 106 | self.ball_stripe.update_stripe(transformation_matrix) 107 | self.update_sprite() 108 | self.ball.update() 109 | 110 | def update_sprite(self): 111 | sprite_dimension = np.repeat([config.ball_radius * 2], 2) 112 | new_sprite = pygame.Surface(sprite_dimension) 113 | colorkey = (200, 200, 200) 114 | new_sprite.fill(self.color) 115 | new_sprite.set_colorkey(colorkey) 116 | 117 | label_dimension = np.repeat([self.label_size * 2], 2) 118 | label = pygame.Surface(label_dimension) 119 | label.fill(self.color) 120 | # 1.1 instead of 1 is a hack to avoid 0 width sprite when scaling 121 | dist_from_centre = 1.1 - (self.label_offset[0] ** 2 + 122 | self.label_offset[1] ** 2) / (config.ball_radius ** 2) 123 | 124 | if self.label_offset[2] > 0: 125 | pygame.draw.circle(label, (255, 255, 255), 126 | label_dimension // 2, self.label_size) 127 | 128 | if self.number != 0: 129 | label.blit(self.text, (config.ball_radius - self.text_length) / 2) 130 | 131 | # hack to avoid div by zero 132 | if self.label_offset[0] != 0: 133 | angle = -math.degrees( 134 | math.atan(self.label_offset[1] / self.label_offset[0])) 135 | label = pygame.transform.scale( 136 | label, (int(config.ball_radius * dist_from_centre), config.ball_radius)) 137 | label = pygame.transform.rotate(label, angle) 138 | 139 | new_sprite.blit( 140 | label, self.label_offset[:2] + (sprite_dimension - label.get_size()) / 2) 141 | if self.ball_type == BallType.Striped: 142 | self.ball_stripe.draw_stripe(new_sprite) 143 | 144 | # applies a circular mask on the sprite using colorkey 145 | grid_2d = np.mgrid[-config.ball_radius:config.ball_radius + 146 | 1, -config.ball_radius:config.ball_radius + 1] 147 | is_outside = config.ball_radius < np.hypot(*grid_2d) 148 | 149 | for xy in itertools.product(range(config.ball_radius * 2 + 1), repeat=2): 150 | if is_outside[xy]: 151 | new_sprite.set_at(xy, colorkey) 152 | 153 | self.image = new_sprite 154 | self.rect = self.image.get_rect() 155 | self.top_left = self.ball.pos - config.ball_radius 156 | self.rect.center = self.ball.pos.tolist() 157 | 158 | def create_image(self, surface, coords): 159 | surface.blit(self.image, coords) 160 | 161 | def is_clicked(self, events): 162 | return physics.distance_less_equal(events["mouse_pos"], self.ball.pos, config.ball_radius) 163 | 164 | def move_to(self, pos): 165 | self.ball.move_to(pos) 166 | self.rect.center = self.ball.pos.tolist() 167 | 168 | def is_active(self, game_state, behind_separation_line=False): 169 | game_state.cue.make_invisible() 170 | events = event.events() 171 | 172 | while events["clicked"]: 173 | events = event.events() 174 | # checks if the user isn't trying to place the ball out of the table or inside another ball 175 | if np.all(np.less(config.table_margin + config.ball_radius + config.hole_radius, events["mouse_pos"])) and \ 176 | np.all(np.greater(config.resolution - config.table_margin - config.ball_radius - config.hole_radius, 177 | events["mouse_pos"])) and \ 178 | not collisions.check_if_ball_touches_balls(events["mouse_pos"], self.number, game_state.balls): 179 | if behind_separation_line: 180 | if events["mouse_pos"][0] <= config.white_ball_initial_pos[0]: 181 | self.move_to(events["mouse_pos"]) 182 | else: 183 | self.move_to(events["mouse_pos"]) 184 | game_state.redraw_all() 185 | game_state.cue.make_visible(game_state.current_player) 186 | -------------------------------------------------------------------------------- /pool/gamestate.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | import math 3 | import random 4 | from enum import Enum 5 | 6 | import numpy as np 7 | import pygame 8 | import zope.event 9 | 10 | import ball 11 | import config 12 | import cue 13 | import event 14 | import graphics 15 | import table_sprites 16 | from ball import BallType 17 | from collisions import check_if_ball_touches_balls 18 | 19 | 20 | class Player(Enum): 21 | Player1 = 1 22 | Player2 = 2 23 | 24 | 25 | class GameState: 26 | def __init__(self): 27 | pygame.init() 28 | pygame.display.set_caption(config.window_caption) 29 | event.set_allowed_events() 30 | zope.event.subscribers.append(self.game_event_handler) 31 | self.canvas = graphics.Canvas() 32 | self.fps_clock = pygame.time.Clock() 33 | 34 | def fps(self): 35 | return self.fps_clock.get_fps() 36 | 37 | def mark_one_frame(self): 38 | self.fps_clock.tick(config.fps_limit) 39 | 40 | def create_white_ball(self): 41 | self.white_ball = ball.BallSprite(0) 42 | ball_pos = config.white_ball_initial_pos 43 | while check_if_ball_touches_balls(ball_pos, 0, self.balls): 44 | ball_pos = [random.randint(int(config.table_margin + config.ball_radius + config.hole_radius), 45 | int(config.white_ball_initial_pos[0])), 46 | random.randint(int(config.table_margin + config.ball_radius + config.hole_radius), 47 | int(config.resolution[1] - config.ball_radius - config.hole_radius))] 48 | self.white_ball.move_to(ball_pos) 49 | self.balls.add(self.white_ball) 50 | self.all_sprites.add(self.white_ball) 51 | 52 | def game_event_handler(self, event): 53 | if event.type == "POTTED": 54 | self.table_coloring.update(self) 55 | self.balls.remove(event.data) 56 | self.all_sprites.remove(event.data) 57 | self.potted.append(event.data.number) 58 | elif event.type == "COLLISION": 59 | if not self.white_ball_1st_hit_is_set: 60 | self.first_collision(event.data) 61 | 62 | def set_pool_balls(self): 63 | counter = [0, 0] 64 | coord_shift = np.array([math.sin(math.radians(60)) * config.ball_radius * 65 | 2, -config.ball_radius]) 66 | initial_place = config.ball_starting_place_ratio * config.resolution 67 | 68 | self.create_white_ball() 69 | # randomizes the sequence of balls on the table 70 | ball_placement_sequence = list(range(1, config.total_ball_num)) 71 | random.shuffle(ball_placement_sequence) 72 | 73 | for i in ball_placement_sequence: 74 | ball_iteration = ball.BallSprite(i) 75 | ball_iteration.move_to(initial_place + coord_shift * counter) 76 | if counter[1] == counter[0]: 77 | counter[0] += 1 78 | counter[1] = -counter[0] 79 | else: 80 | counter[1] += 2 81 | self.balls.add(ball_iteration) 82 | 83 | self.all_sprites.add(self.balls) 84 | 85 | def start_pool(self): 86 | self.reset_state() 87 | self.generate_table() 88 | self.set_pool_balls() 89 | self.cue = cue.Cue(self.white_ball) 90 | self.all_sprites.add(self.cue) 91 | 92 | def reset_state(self): 93 | # game state variables 94 | self.current_player = Player.Player1 95 | self.turn_ended = True 96 | self.white_ball_1st_hit_is_set = False 97 | self.potted = [] 98 | self.balls = pygame.sprite.Group() 99 | self.holes = pygame.sprite.Group() 100 | self.all_sprites = pygame.sprite.OrderedUpdates() 101 | self.turn_number = 0 102 | self.ball_assignment = None 103 | self.can_move_white_ball = True 104 | self.is_game_over = False 105 | self.potting_8ball = {Player.Player1: False, Player.Player2: False} 106 | self.table_sides = [] 107 | 108 | def is_behind_line_break(self): 109 | # 1st break should be made from behind the separation line on the table 110 | return self.turn_number == 0 111 | 112 | def redraw_all(self, update=True): 113 | self.all_sprites.clear(self.canvas.surface, self.canvas.background) 114 | self.all_sprites.draw(self.canvas.surface) 115 | self.all_sprites.update(self) 116 | if update: 117 | pygame.display.flip() 118 | self.mark_one_frame() 119 | 120 | def all_not_moving(self): 121 | return_value = True 122 | for ball in self.balls: 123 | if np.count_nonzero(ball.ball.velocity) > 0: 124 | return_value = False 125 | break 126 | return return_value 127 | 128 | def generate_table(self): 129 | table_side_points = np.empty((1, 2)) 130 | # holes_x and holes_y holds the possible xs and ys of the table holes 131 | # with a position ID in the second tuple field 132 | # so the top left hole has id 1,1 133 | holes_x = [(config.table_margin, 1), (config.resolution[0] / 134 | 2, 2), (config.resolution[0] - config.table_margin, 3)] 135 | holes_y = [(config.table_margin, 1), 136 | (config.resolution[1] - config.table_margin, 2)] 137 | # next three lines are a hack to make and arrange the hole coordinates 138 | # in the correct sequence 139 | all_hole_positions = np.array( 140 | list(itertools.product(holes_y, holes_x))) 141 | all_hole_positions = np.fliplr(all_hole_positions) 142 | all_hole_positions = np.vstack( 143 | (all_hole_positions[:3], np.flipud(all_hole_positions[3:]))) 144 | for hole_pos in all_hole_positions: 145 | self.holes.add(table_sprites.Hole(hole_pos[0][0], hole_pos[1][0])) 146 | # this will generate the diagonal, vertical and horizontal table 147 | # pieces which will reflect the ball when it hits the table sides 148 | # 149 | # they are generated using 4x2 offset matrices (4 2d points around the hole) 150 | # with the first point in the matrix is the starting point and the 151 | # last point is the ending point, these 4x2 matrices are 152 | # concatenated together 153 | # 154 | # the martices must be flipped using numpy.flipud() 155 | # after reflecting them using 2x1 reflection matrices, otherwise 156 | # starting and ending points would be reversed 157 | if hole_pos[0][1] == 2: 158 | # hole_pos[0,1]=2 means x coordinate ID is 2 which means this 159 | # hole is in the middle 160 | offset = config.middle_hole_offset 161 | else: 162 | offset = config.side_hole_offset 163 | if hole_pos[1][1] == 2: 164 | offset = np.flipud(offset) * [1, -1] 165 | if hole_pos[0][1] == 1: 166 | offset = np.flipud(offset) * [-1, 1] 167 | table_side_points = np.append( 168 | table_side_points, [hole_pos[0][0], hole_pos[1][0]] + offset, axis=0) 169 | # deletes the 1st point in array (leftover form np.empty) 170 | table_side_points = np.delete(table_side_points, 0, 0) 171 | for num, point in enumerate(table_side_points[:-1]): 172 | # this will skip lines inside the circle 173 | if num % 4 != 1: 174 | self.table_sides.append(table_sprites.TableSide( 175 | [point, table_side_points[num + 1]])) 176 | self.table_sides.append(table_sprites.TableSide( 177 | [table_side_points[-1], table_side_points[0]])) 178 | self.table_coloring = table_sprites.TableColoring( 179 | config.resolution, config.table_side_color, table_side_points) 180 | self.all_sprites.add(self.table_coloring) 181 | self.all_sprites.add(self.holes) 182 | graphics.add_separation_line(self.canvas) 183 | 184 | def game_over(self, p1_won): 185 | font = config.get_default_font(config.game_over_label_font_size) 186 | if p1_won: 187 | text = "PLAYER 1 WON!" 188 | else: 189 | text = "PLAYER 2 WON!" 190 | rendered_text = font.render(text, False, (255, 255, 255)) 191 | self.canvas.surface.blit(rendered_text, (config.resolution - font.size(text)) / 2) 192 | pygame.display.flip() 193 | pygame.event.clear() 194 | paused = True 195 | while paused: 196 | event = pygame.event.wait() 197 | if event.type == pygame.QUIT or event.type == pygame.KEYDOWN or event.type == pygame.MOUSEBUTTONDOWN: 198 | paused = False 199 | self.is_game_over = True 200 | 201 | def turn_over(self, penalize): 202 | if not self.turn_ended: 203 | self.turn_ended = True 204 | self.turn_number += 1 205 | if self.current_player == Player.Player1: 206 | self.current_player = Player.Player2 207 | else: 208 | self.current_player = Player.Player1 209 | if penalize: 210 | self.can_move_white_ball = True 211 | 212 | def check_potted(self): 213 | self.can_move_white_ball = False # if white ball is potted, it will be created again and placed in the middle 214 | if 0 in self.potted: 215 | self.create_white_ball() 216 | self.cue.target_ball = self.white_ball 217 | self.potted.remove(0) 218 | self.turn_over(True) 219 | if 8 in self.potted: 220 | if self.potting_8ball[self.current_player]: 221 | self.game_over(self.current_player == Player.Player1) 222 | else: 223 | self.game_over(self.current_player != Player.Player1) 224 | 225 | def check_remaining(self): 226 | # a check if all striped or solid balls were potted 227 | stripes_remaining = False 228 | solids_remaining = False 229 | for remaining_ball in self.balls: 230 | if remaining_ball.number != 0 and remaining_ball.number != 8: 231 | stripes_remaining = stripes_remaining or remaining_ball.ball_type == BallType.Striped 232 | solids_remaining = solids_remaining or not remaining_ball.ball_type == BallType.Striped 233 | ball_type_remaining = {BallType.Solid: solids_remaining, BallType.Striped: stripes_remaining} 234 | 235 | # decides if on of the players (or both) should be potting 8ball 236 | self.potting_8ball = {Player.Player1: not ball_type_remaining[self.ball_assignment[Player.Player1]], 237 | Player.Player2: not ball_type_remaining[self.ball_assignment[Player.Player2]]} 238 | 239 | def first_collision(self, ball_combination): 240 | self.white_ball_1st_hit_is_set = True 241 | self.white_ball_1st_hit_8ball = ball_combination[0].number == 8 or ball_combination[1].number == 8 242 | if ball_combination[0].number == 0: 243 | self.white_ball_1st_hit_type = ball_combination[1].ball_type 244 | else: 245 | self.white_ball_1st_hit_type = ball_combination[0].ball_type 246 | 247 | def check_pool_rules(self): 248 | if self.ball_assignment is not None: 249 | self.check_remaining() 250 | self.check_potted() 251 | self.first_hit_rule() 252 | self.potted_ball_rules() 253 | self.on_next_hit() 254 | 255 | def on_next_hit(self): 256 | self.white_ball_1st_hit_is_set = False 257 | self.turn_ended = False 258 | self.potted = [] 259 | 260 | def potted_ball_rules(self): 261 | if len(self.potted) > 0: 262 | # if it wasnt decided which player goes for which type of balls 263 | # and the player potted the balls exclusively of one color (excluting white balls) 264 | # then it is decided based on which players turn it is right now and which type 265 | # of balls he potted 266 | potted_stripe_count = len([x for x in self.potted if x > 8]) 267 | potted_solid_count = len([x for x in self.potted if x < 8]) 268 | only_stripes_potted = potted_solid_count == 0 and potted_stripe_count > 0 269 | only_solids_potted = potted_stripe_count == 0 and potted_solid_count > 0 270 | 271 | if only_solids_potted or only_stripes_potted: 272 | selected_ball_type = BallType.Striped if only_stripes_potted else BallType.Solid 273 | if self.ball_assignment is None: 274 | # unpacking a singular set - SO MACH HACK 275 | other_player, = set(Player) - {self.current_player} 276 | other_ball_type, = set(BallType) - {selected_ball_type} 277 | self.ball_assignment = {self.current_player: selected_ball_type, other_player: other_ball_type} 278 | self.potting_8ball = {self.current_player: False, other_player: False} 279 | elif self.ball_assignment[self.current_player] != selected_ball_type: 280 | self.turn_over(False) 281 | else: 282 | self.turn_over(False) 283 | 284 | def first_hit_rule(self): 285 | # checks if the 1st white ball hit is the same as the players target ball type 286 | # for example if the current player hits a striped ball with the whit ball 287 | # but he should be potting solid balls, it is next players turn and he can move the white ball 288 | if not self.white_ball_1st_hit_is_set: 289 | self.turn_over(True) 290 | elif self.ball_assignment is not None: 291 | if not self.white_ball_1st_hit_8ball and self.ball_assignment[ 292 | self.current_player] != self.white_ball_1st_hit_type: 293 | self.turn_over(True) 294 | # checks if the 8ball was the first ball hit, and if so checks if the player needs to pot the 8ball 295 | # and if not he gets penalised 296 | elif self.white_ball_1st_hit_8ball: 297 | self.turn_over(not self.potting_8ball[self.current_player]) 298 | --------------------------------------------------------------------------------