diff --git a/.flake8 b/.flake8 index 1faf867..b1e6c6f 100644 --- a/.flake8 +++ b/.flake8 @@ -1,2 +1,2 @@ [flake8] -ignore = F401,E231,E401,E402,F401,F403,F405,E301,W391,E261,E302,E241,E131,W293,E122,E125,W504,E305 +ignore = F401,E231,E401,E402,F401,F403,F405,E301,W391,E261,E302,E241,E131,W293,E122,E125,W504,E305,F821 diff --git a/.github/workflows/pythonapp.yml b/.github/workflows/pythonapp.yml new file mode 100644 index 0000000..468270d --- /dev/null +++ b/.github/workflows/pythonapp.yml @@ -0,0 +1,38 @@ +# This workflow will install Python dependencies, run tests and lint with a single version of Python +# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions + +name: Python application + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - name: Set up Python 3.8 + uses: actions/setup-python@v1 + with: + python-version: 3.7 + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + - name: Lint with flake8 + run: | + pip install flake8 + # stop the build if there are Python syntax errors or undefined names + flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics + # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide + flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + - name: Test with pytest + run: | + pip install pytest + cd tests + pytest diff --git a/.gitignore b/.gitignore index 91d66be..b477e61 100644 --- a/.gitignore +++ b/.gitignore @@ -132,11 +132,9 @@ dmypy.json # Pyre type checker .pyre/ -# Visual Studio Code -.vscode/ - # PyCharm .idea/ .DS_Store +.vscode/ diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..3022f6a --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "python.linting.pylintEnabled": false, + "python.linting.flake8Enabled": true, + "python.linting.enabled": true, + "python.formatting.provider": "black" +} \ No newline at end of file diff --git a/README.md b/README.md index 6001d70..9b2bdbe 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,77 @@ -# PyWeek29 -PythonixCoders' Entry for PyWeek29 +# BUTTERFLY DESTROYERS + +A Game Project for the PyWeek29 game jam, built in one week by Team PythonixCoders! + +![Build Status](https://github.com/PythonixCoders/PyWeek29/workflows/Python%20application/badge.svg?branch=master) + +![Screenshot](data/screenshot.png) +## Team Members + +- [flipcoder](https://github.com/flipcoder/) +- [ddorn](https://gitlab.com/ddorn/) +- [MysteryCoder456](https://github.com/MysteryCoder456/) +- [tamwile](https://github.com/tamwile/) +- [jtiai](https://github.com/jtiai/) + +## How to Play + +``` +pip install -r requirements.txt +python ./run_game.py +``` + +Or in a virtual environement : + +```shell script +git clone https://github.com/PythonixCoders/PyWeek29.git butterfly-destroyer +cd butterfly-destroyer +python -m venv .env +source .env/bin/activate +pip install -r requirements.txt +python run_game.py +``` + +The keyboard controls are simple : + - WASD or arrows to move + - Space or enter to shoot + - Shift to change weapons after unlock + +But the game is better with a controler, +however it was only tested with an Xbox controler +and there is no guarantee that it will work with +correct mappings on others. + - Main sticks to move + - A/B or RB/LB to shoot + - X/Y to change weapons after unlock + +## Content + +One of the most important thing missing is +a way to select the levels. If you quit the game and +want to start at a certain level, just pass the number of +the level to the command line. For instance: + +```shell script +python run_game.py 4 # Any number between 1 and 7 included +python run_game.py intro +python run_game.py credits +``` + +Most of the game is done, but as the deadline approched, +we did not have enough time to make all the levels we wanted +and the last levels need a lot more love that they received. +Some of them were even abandoned in a hurry. +Let's have a minute of silence for them. + +If you want to know what the rest of the game would +have looked like, you can check the [scenario](data/scenario.md) +and use your imagination to design good levels with well balanced +*ButtaBombers* and *Flyers*. Also imagine the final boss with nice +paterns. + +## Credits +Ship graphic: https://opengameart.org/users/pitrizzo + +Font: https://www.dafont.com/press-start-2p.font + + ![thanks for playing !](data/thanks_for_playing.png) diff --git a/data/Inconsolata-g.ttf b/data/Inconsolata-g.ttf deleted file mode 100644 index 1a212c6..0000000 Binary files a/data/Inconsolata-g.ttf and /dev/null differ diff --git a/data/music/.gitkeep b/data/music/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/data/music/butterfly.ogg b/data/music/butterfly.ogg new file mode 100644 index 0000000..5a6e513 Binary files /dev/null and b/data/music/butterfly.ogg differ diff --git a/data/music/butterfly2.ogg b/data/music/butterfly2.ogg new file mode 100644 index 0000000..d416ada Binary files /dev/null and b/data/music/butterfly2.ogg differ diff --git a/data/music/intermission.ogg b/data/music/intermission.ogg new file mode 100644 index 0000000..333265a Binary files /dev/null and b/data/music/intermission.ogg differ diff --git a/data/scenario.md b/data/scenario.md new file mode 100644 index 0000000..788712b --- /dev/null +++ b/data/scenario.md @@ -0,0 +1,126 @@ +## Scenario and level design + +#### Intro - Done + +In the year 20XX, the butterfly +overpopulation problem has +obviously reached critical mass. +The military has decided to intervene. +Your mission is simple: defeat all the +butterflies before the world ends. +But look out for Big Butta, king of +the butterflies... + +#### Tuto + +We need a simple was to show the controls without tooo much text +Should not be too long to go through as controls are simple and usual + +Explain + - Note: The game is played better with a gamepad + - Basic movement (WASD, arrows or gamepad) + - Fire (space, enter, gamepad trigger, gamepad A or B) +Don't explain how to switch weapons as there is only one available + +#### Level 1 - Done + +**Title**: The Butterflies Awaken + +A few butterflies spawn in simple structures, +but very soon they start to organise themselves +into more complexe and dense structures +At the end the player faces a wall of non moving butterlies + +#### Level 2 + +**Title**: The Rise of Butterfly + +A few dense structures and then butterflies start +moving in increasingly complexe shape. +Finally a Machine Gun is given to the player that allows +it to destrow the final wall. +When the machine Gun is give a small tuto + tells how to change weapons + +#### Level 3 + +**Title**: Attack of the Butterflies + +One night, the butterflies gathered to stop you. +A very dense level were the Machine will be of +great use, with heart powerup. Quite lengthy, +with new structures and more butterflies than ever. +Over time, attacks are less dense but butterflies move faster +Towards the end, the player is given the Laser Gun +to kill butterflies before they can see him. + +#### Level 4 + +**Title**: The Butterfly Menace + +After the mass killings, butterflies went +hiding in the desert where they will try to avoid +the player. +Very sparse and spread out waves at first, +they then come in a bigger number and avoiding better +when the player is given Aiming bullets. +The rest of the level is then to enjoy the Aiming bullets + +#### Level 5 + +**Title**: The Butterflies Strikes Back + +After their last beating, +they put their hands on guns and +start to attack the player. +A few butterflies spawn first so +the player can learn to avoid bullets. +The machine gun will be used a lot to avoid +getting too many butterflies on the screen +There should be some snipers (butterflies far from the crowd) + +#### Level 6 +**Title**: A new Butterfly + +The butterfly try to take an other approch to +stop you. This level introduces Flyers +that traverse the screen and shoot at the player. +It will be a hard level that needs a lot of Aiming bullets +and hearts. + +#### Level 7 +**Title**: Return of the Butterfly +An old war butterfly has finally decided +to go one more time to the front. +And he is willing to give its life to this war. +It introduce a strong ButtaBomber with a lot of other +simple butterflies. + +#### Level 8 +**Title**: Revenge of the Butterfly + +The butterflies decide to give everything they have +in one last battle. This is the final level +that will need a bit of every weapon and patterns. + +#### Level 9 +**Title**: The Last Butterfly + +All the butterflies have been killed. +But there is one left. Their King. +And he is FUCKING ANGRY. + +This. +Is. +Bigg Butta. + +#### The End +(text or graphics if we have time) + +Bigg Butta dies and explodes in zillions +of butterflies that take over the exausted +ship and make it one of them. +The newborn innocent butterflies repopulate +the earth for the joy of all humanity, +that had starteda long time ago to wonder +what was the point of this massacre diff --git a/data/screenshot.png b/data/screenshot.png new file mode 100644 index 0000000..af49c5d Binary files /dev/null and b/data/screenshot.png differ diff --git a/data/sounds/butterfly.mp3 b/data/sounds/butterfly.mp3 new file mode 100644 index 0000000..efc3005 Binary files /dev/null and b/data/sounds/butterfly.mp3 differ diff --git a/data/sounds/butterfly.wav b/data/sounds/butterfly.wav new file mode 100644 index 0000000..a956f51 Binary files /dev/null and b/data/sounds/butterfly.wav differ diff --git a/data/sounds/explosion.wav b/data/sounds/explosion.wav new file mode 100644 index 0000000..a988e29 Binary files /dev/null and b/data/sounds/explosion.wav differ diff --git a/data/sounds/fire.wav b/data/sounds/fire.wav new file mode 100644 index 0000000..b6e4e64 Binary files /dev/null and b/data/sounds/fire.wav differ diff --git a/data/sounds/hit.wav b/data/sounds/hit.wav new file mode 100644 index 0000000..fc355b0 Binary files /dev/null and b/data/sounds/hit.wav differ diff --git a/data/sounds/hurt.wav b/data/sounds/hurt.wav new file mode 100644 index 0000000..5bcac17 Binary files /dev/null and b/data/sounds/hurt.wav differ diff --git a/data/sounds/laser.wav b/data/sounds/laser.wav new file mode 100644 index 0000000..a96b2fa Binary files /dev/null and b/data/sounds/laser.wav differ diff --git a/data/sounds/lightning.wav b/data/sounds/lightning.wav new file mode 100644 index 0000000..a4d3e60 Binary files /dev/null and b/data/sounds/lightning.wav differ diff --git a/data/sounds/message.wav b/data/sounds/message.wav new file mode 100644 index 0000000..a898b87 Binary files /dev/null and b/data/sounds/message.wav differ diff --git a/data/sounds/powerup.wav b/data/sounds/powerup.wav new file mode 100644 index 0000000..09f7d5c Binary files /dev/null and b/data/sounds/powerup.wav differ diff --git a/data/sounds/shield.wav b/data/sounds/shield.wav new file mode 100644 index 0000000..dc0e7ff Binary files /dev/null and b/data/sounds/shield.wav differ diff --git a/data/sounds/shoot.wav b/data/sounds/shoot.wav new file mode 100644 index 0000000..d61114f Binary files /dev/null and b/data/sounds/shoot.wav differ diff --git a/data/sounds/squeak.wav b/data/sounds/squeak.wav new file mode 100644 index 0000000..05936a6 Binary files /dev/null and b/data/sounds/squeak.wav differ diff --git a/data/sounds/warning.wav b/data/sounds/warning.wav new file mode 100644 index 0000000..b61a65d Binary files /dev/null and b/data/sounds/warning.wav differ diff --git a/data/sprites/bullet.aseprite b/data/sprites/bullet.aseprite new file mode 100644 index 0000000..8b1d8a3 Binary files /dev/null and b/data/sprites/bullet.aseprite differ diff --git a/data/sprites/blue_lepidopter.gif b/data/sprites/buttabomber.gif similarity index 100% rename from data/sprites/blue_lepidopter.gif rename to data/sprites/buttabomber.gif diff --git a/data/sprites/buttabomber.png b/data/sprites/buttabomber.png new file mode 100644 index 0000000..152e1eb Binary files /dev/null and b/data/sprites/buttabomber.png differ diff --git a/data/sprites/butterfly2.aseprite b/data/sprites/butterfly2.aseprite new file mode 100644 index 0000000..073a3bc Binary files /dev/null and b/data/sprites/butterfly2.aseprite differ diff --git a/data/sprites/cloud1.png b/data/sprites/cloud1.png new file mode 100644 index 0000000..26af386 Binary files /dev/null and b/data/sprites/cloud1.png differ diff --git a/data/sprites/cloud2.png b/data/sprites/cloud2.png new file mode 100644 index 0000000..6b9c37e Binary files /dev/null and b/data/sprites/cloud2.png differ diff --git a/data/sprites/cloud3.png b/data/sprites/cloud3.png new file mode 100644 index 0000000..21bf9d6 Binary files /dev/null and b/data/sprites/cloud3.png differ diff --git a/data/sprites/cloud4.png b/data/sprites/cloud4.png new file mode 100644 index 0000000..a1f818b Binary files /dev/null and b/data/sprites/cloud4.png differ diff --git a/data/sprites/cloud5.png b/data/sprites/cloud5.png new file mode 100644 index 0000000..3fc9698 Binary files /dev/null and b/data/sprites/cloud5.png differ diff --git a/data/sprites/cloud6.png b/data/sprites/cloud6.png new file mode 100644 index 0000000..ff13caa Binary files /dev/null and b/data/sprites/cloud6.png differ diff --git a/data/sprites/cloud7.png b/data/sprites/cloud7.png new file mode 100644 index 0000000..cd3e2ae Binary files /dev/null and b/data/sprites/cloud7.png differ diff --git a/data/sprites/clouds.aseprite b/data/sprites/clouds.aseprite new file mode 100644 index 0000000..6b8b220 Binary files /dev/null and b/data/sprites/clouds.aseprite differ diff --git a/data/sprites/clouds.png b/data/sprites/clouds.png new file mode 100644 index 0000000..2f10b3f Binary files /dev/null and b/data/sprites/clouds.png differ diff --git a/data/sprites/crosshair.aseprite b/data/sprites/crosshair.aseprite new file mode 100644 index 0000000..17298a8 Binary files /dev/null and b/data/sprites/crosshair.aseprite differ diff --git a/data/sprites/crosshair.png b/data/sprites/crosshair.png new file mode 100644 index 0000000..1f0bed0 Binary files /dev/null and b/data/sprites/crosshair.png differ diff --git a/data/sprites/crosshair_green.png b/data/sprites/crosshair_green.png new file mode 100644 index 0000000..6fa4190 Binary files /dev/null and b/data/sprites/crosshair_green.png differ diff --git a/data/sprites/flyer.png b/data/sprites/flyer.png new file mode 100644 index 0000000..05fe364 Binary files /dev/null and b/data/sprites/flyer.png differ diff --git a/data/sprites/flyer2.png b/data/sprites/flyer2.png new file mode 100644 index 0000000..5749b70 Binary files /dev/null and b/data/sprites/flyer2.png differ diff --git a/data/sprites/smoke.png b/data/sprites/smoke.png new file mode 100644 index 0000000..fefe167 Binary files /dev/null and b/data/sprites/smoke.png differ diff --git a/data/thanks_for_playing.png b/data/thanks_for_playing.png new file mode 100644 index 0000000..48afe4d Binary files /dev/null and b/data/thanks_for_playing.png differ diff --git a/data/unifont.ttf b/data/unifont.ttf deleted file mode 100644 index f09768b..0000000 Binary files a/data/unifont.ttf and /dev/null differ diff --git a/game/__init__.py b/game/__init__.py index 9dce6ab..8bb9da9 100755 --- a/game/__init__.py +++ b/game/__init__.py @@ -1,12 +1,19 @@ #!/usr/bin/python import sys -from game.app import App -from game.states.game import Game -from game.states.intro import Intro def main(): - state = sys.argv[-1] if len(sys.argv) >= 2 else "game" + from game import constants + + # Do it before everything so modules + # can import it with the right value + if "--debug" in sys.argv: + constants.DEBUG = True + sys.argv.remove("--debug") + + from game.base.app import App + + state = sys.argv[-1] if len(sys.argv) >= 2 else "intro" return App(state).run() diff --git a/game/app.py b/game/app.py deleted file mode 100755 index 761c96f..0000000 --- a/game/app.py +++ /dev/null @@ -1,124 +0,0 @@ -#!/usr/bin/python -import pygame -from glm import ivec2 - -from game.util.signal import Signal - -from game.states.game import Game -from game.states.intro import Intro - - -class App: - - STATES = {"intro": Intro, "game": Game} - - def __init__(self, initial_state): - """ - The main beginning of our application. - Initializes pygame and the initial state. - """ - - pygame.init() - - self.size = ivec2(1920, 1080) / 2 - """Display size""" - self.cache = {} - """Resources with filenames as keys""" - self.screen = pygame.display.set_mode(self.size) - self.on_event = Signal() - self.quit = False - self.clock = pygame.time.Clock() - self.time = 0 - self.dirty = True - - self.state = initial_state - - def load(self, filename, resource_func): - """ - Attempt to load a resource from the cache, otherwise, loads it - :param resource_func: a function that loads the resource if its - not already available in the cache - """ - if filename not in self.cache: - r = self.cache[filename] = resource_func() - return r - return self.cache[filename] - - # def pend(self): - - # self.dirty = True - - def run(self): - """ - Main game loop. - - Runs until the `quit` flag is set - Runs update(dt) and render() of the current game state (default: Game) - """ - - while (not self.quit) and self.state: - - dt = self.clock.tick(60) / 1000 - self.time += dt - - for event in pygame.event.get(): - if event.type == pygame.QUIT: - return 0 - self.on_event(event) - - if self.state is None: - break - - if self.update(dt) is False: - break - - if self.render() is False: - break - - def add_event_listener(self, obj): - slot = self.on_event.connect(obj.event, weak=True) - obj.slots.append(slot) - return slot - - def keys(self): - return pygame.key.get_pressed() - - def update(self, dt): - """ - Called every frame to update our game logic - :param dt: time since last frame in seconds - :return: returns False to quit gameloop - """ - - if not self.state: - return False - - self.state.update(dt) - - def render(self): - """ - Called every frame to render our game state and update pygame display - :return: returns False to quit gameloop - """ - - # if not self.dirty: - # return - # self.dirty = False - - if self.state is None: - return False - - self.state.render() - - pygame.display.update() - - @property - def state(self): - return self._state - - @state.setter - def state(self, s): - if isinstance(s, str): - self.state = self.STATES[s.lower()](self) - return - self._state = s diff --git a/game/base/app.py b/game/base/app.py new file mode 100755 index 0000000..014925d --- /dev/null +++ b/game/base/app.py @@ -0,0 +1,219 @@ +#!/usr/bin/python +import os + +import pygame +from glm import ivec2, vec2 + +from game.base.inputs import Inputs +from game.base.signal import Signal +from game.constants import SPRITES_DIR, DEBUG +from game.base.stats import Stats + +from game.states.game import Game +from game.states.intro import Intro +from game.states.menu import Menu +from game.states.credits import Credits +from game.states.intermission import Intermission +import time + + +class App: + + STATES = { + "intro": Intro, + "game": Game, + "menu": Menu, + "intermission": Intermission, + "credits": Credits, + } + # MAX_KEYS = 512 + + def __init__(self, initial_state): + """ + The main beginning of our application. + Initializes pygame and the initial state. + """ + + # pygame.mixer.pre_init(44100, 16, 2, 4096) + pygame.init() + + self.size = ivec2(1920, 1080) / 2 + """Display size""" + self.cache = {} + """Resources with filenames as keys""" + pygame.display.set_caption("Butterfly Destroyers") + self.screen = pygame.display.set_mode(self.size) + self.on_event = Signal() + self.quit = False + self.clock = pygame.time.Clock() + self.inputs = Inputs() + self.time = 0 + self.dirty = True + self.data = {} # data persisting between modes + # self.keys = [False] * self.MAX_KEYS + + self._state = None + self.last_state = None + self.next_state = initial_state + self.process_state_change() + + def load(self, filename, resource_func): + """ + Attempt to load a resource from the cache, otherwise, loads it + :param resource_func: a function that loads the resource if its + not already available in the cache + """ + if filename not in self.cache: + r = self.cache[filename] = resource_func() + return r + return self.cache[filename] + + def load_img(self, filename, scale=1, flipped=False): + """ + Load the image at the given path in a pygame surface. + The file name is the name of the file without the full path. + Files are looked for in the SPRITES_DIR + Results are cached. + Scale is an optional integer to scale the image by a given factor. + """ + + def load_fn(): + img = pygame.image.load(os.path.join(SPRITES_DIR, filename)) + if scale != 1: + w, h = img.get_size() + img = pygame.transform.scale(img, ivec2(vec2(w, h) * scale)) + if flipped: + img = pygame.transform.flip(img, True, False) + return img + + return self.load((filename, scale, flipped), load_fn) + + # def pend(self): + + # self.dirty = True + + def run(self): + """ + Main game loop. + + Runs until the `quit` flag is set + Runs update(dt) and render() of the current game state (default: Game) + """ + + last_t = time.time_ns() + accum = 0 + self.fps = 0 + frames = 0 + dt = 0 + + self.inputs.event([]) + + while (not self.quit) and self.state: + + cur_t = time.time_ns() + dt += (cur_t - last_t) / (1000 * 1000 * 1000) + + # if dt < 0.001: + # time.sleep(1 / 300) + # continue # accumulate dt for skipped frames + + last_t = cur_t + accum += dt + frames += 1 + if accum > 1: + self.fps = frames + frames = 0 + accum -= 1 + + events = pygame.event.get() + for event in events: + if event.type == pygame.QUIT: + return 0 + self.on_event(event) + + self.inputs.event(events) + + if self.state is None: + break + + if DEBUG: + print("FRAME, dt =", dt, "FPS,", self.fps) + + self.inputs.update(dt) + if self.update(dt) is False: + break + + if self.render() is False: + break + + dt = 0 # reset to accumulate + + def add_event_listener(self, obj): + slot = self.on_event.connect(obj.event) + obj.slots.append(slot) + return slot + + def update(self, dt): + """ + Called every frame to update our game logic + :param dt: time since last frame in seconds + :return: returns False to quit gameloop + """ + + if not self.state: + return False + + if self.next_state: + self.process_state_change() + + self.state.update(dt) + + def render(self): + """ + Called every frame to render our game state and update pygame display + :return: returns False to quit gameloop + """ + + # if not self.dirty: + # return + # self.dirty = False + + if self.state is None: + return False + + self.state.render() + + pygame.display.update() + + @property + def state(self): + return self._state + + @state.setter + def state(self, s): + """ + Schedule state change on next frame + """ + self.next_state = s + + def process_state_change(self): + """ + Process pending state changes + """ + lvl = None + + try: + lvl = int(self.next_state) + pass + except ValueError: + pass + + if lvl: + stats = self.data["stats"] = self.data.get("stats", Stats()) + stats.level = lvl + self.next_state = "game" + + if self.next_state: + self._state = self.STATES[self.next_state.lower()](self) + + self.next_state = None diff --git a/game/base/being.py b/game/base/being.py new file mode 100644 index 0000000..96d401d --- /dev/null +++ b/game/base/being.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python + +from game.base.entity import Entity +from game.base.stats import Stats +from game.constants import * +import random +from glm import vec3 + + +class Being(Entity): + """ + An entity with HP + """ + + def __init__(self, app, scene, filename=None, **kwargs): + super().__init__(app, scene, filename, **kwargs) + self.solid = True + self.hp = 1 + self.stats = None + self.alive = True # prevent mutliple kill() + self.friendly = False + self.stats = Stats() + + def hurt(self, dmg, bullet, damager): + """ + Apply damage from bullet shot by damager + Returns amount of damage taken (won't be more than self.hp) + """ + if not self.hp: + return 0 + dmg_taken = min(self.hp, dmg) + if dmg_taken > 0: + self.hp -= dmg_taken + assert self.hp >= 0 + killed = False + if self.hp == 0: + killed = self.kill(dmg_taken, bullet, damager) + if isinstance(damager, Being) and damager.stats: + damager.stats.kills += int(killed) + self.stats.damage_taken += dmg_taken + damager.stats.damage_done += dmg_taken + damager.stats.score += max(int(dmg_taken), 1) + return dmg_taken + + def explode(self): + from game.entities.blast import Blast # hack: cicular inclusion + + for x in range(10): + self.scene.add( + Entity( + self.app, + self.scene, + "bullet.png", + position=self.position, + velocity=self.velocity + + ( + vec3(random.random(), random.random(), random.random()) + - vec3(0.5) + ) + * 100, + life=1 + random.random() * 2, + particle=True, + acceleration=-Y * 100, + ), + ) + self.scene.add( + Blast( + self.app, + self.scene, + 3, # radius + random.choice(("orange", "yellow")), + 0, # no damage, just visual + 1, # spread + position=self.position, + velocity=self.velocity, + life=0.3, + ), + ) diff --git a/game/base/enemy.py b/game/base/enemy.py new file mode 100644 index 0000000..8120c76 --- /dev/null +++ b/game/base/enemy.py @@ -0,0 +1,9 @@ +#!/usr/bin/env python + +from game.base.being import Being + + +class Enemy(Being): + def __init__(self, app, scene, **kwargs): + super().__init__(app, scene, **kwargs) + self.friendly = False diff --git a/game/base/entity.py b/game/base/entity.py index d903e3d..6c3114f 100755 --- a/game/base/entity.py +++ b/game/base/entity.py @@ -1,36 +1,118 @@ #!/usr/bin/python -from glm import vec2, vec3, ivec2 -from game.util.signal import Signal +from typing import TYPE_CHECKING + +import pygame +from glm import ivec2 +from pygame.surface import SurfaceType + +from game.base.script import Script +from game.base.signal import Signal, SlotList from game.constants import * from os import path -from game.util.util import * + +from game.util import * + +if TYPE_CHECKING: + from game.base.app import App + from game.entities.ai import AI class Entity: """ - A basic component of the game. + A basic component of the game scene. An Entity represents something that will be draw on the screen. """ - def __init__(self, app, scene, fn=None, **kwargs): - self.app = app + def __init__(self, app, scene, filename=None, **kwargs): + # print(type(self)) + self.app: "App" = app self.scene = scene - self._position = kwargs.get("position") or vec3(0) - self._velocity = kwargs.get("velocity") or vec3(0) - self._life = kwargs.get("life") + self.slot = None # weakref + self.slots = SlotList() + self.scripts = Signal(lambda fn: Script(self.app, self, fn, use_input=False)) + self.life = kwargs.pop("life", None) # particle life (length of time to exist) self.on_move = Signal() + self.on_update = Signal() self.on_remove = Signal() # self.dirty = True - self.slots = [] self._surface = None self.removed = False + self.parent = kwargs.pop("parent", None) + self.sounds = {} + self.particle = kwargs.pop("particle", None) + self.visible = True + self._script_func = False + + script = kwargs.pop("script", None) + self.script = None # main script + + self._position = kwargs.pop("position", vec3(0)) + self.velocity = kwargs.pop("velocity", vec3(0)) + self.acceleration = kwargs.pop("acceleration", vec3(0)) + + # solid means its collision-checked against other things + # has_collision means the entity has a collision() callback + self.has_collision = hasattr(self, "collision") + self.solid = self.has_collision + # if self.has_collision: + # print(self, 'has collision') + # if self.solid: + # print(self, 'is solid') + + self.filename = filename + if filename: + self._surface = self.app.load_img(filename, kwargs.pop("scale", 1)) + self.collision_size = self.size = estimate_3d_size(self._surface.get_size()) + else: + self.collision_size = self.size = vec3(0) + self.render_size = vec3(0) + """Should hold the size in pixel at which the entity was last rendered""" - self.fn = fn - if fn: - self._surface = self.app.load( - fn, lambda: pygame.image.load(path.join(SPRITES_DIR, fn)) + if hasattr(self, "event"): + self.slots += app.add_event_listener(self) + + if isinstance(script, str): + # load script from string 'scripts/' folder + self.script = script + self.scripts += self.script + + if callable(self): + # use __call__ as script + self.script = self + self.scripts += self + + ai = kwargs.pop("ai", None) + self.ai: "AI" = ai(self) if ai else None + + if kwargs: + raise ValueError( + "kwrgs for Entity have not all been consumed. Left:", kwargs ) + def clear_scripts(self): + self.scripts = Signal(lambda fn: Script(self.app, self, fn, use_input=False)) + + # def add_script(self, fn): + # """ + # :param fn: add script `fn` (cls, func, or filename) + # """ + # self.scripts += script + # return script + + def __str__(self): + return f"{self.__class__.__name__}(pos: {self.position}, id: {id(self)})" + + # def once(self, duration, func) + # """ + # A weakref version of scene.when.once. + # Used for safely triggering temp one-time events w/o holding the slot. + # """ + # return self.scene.when.once( + # duration, + # lambda wself=weakref.ref(self): func(wself), + # weak=False + # ) + @property def position(self): return self._position @@ -49,6 +131,12 @@ def position(self, v): print("Entity:", self) raise ValueError + if v is None: + v = vec3(0) + + if v.x != v.x: + raise ValueError + self._position = vec3(*v) self.on_move() @@ -57,29 +145,32 @@ def velocity(self): return self._velocity @velocity.setter - def velocity(self, v): - """ - Sets position of our entity, which controls where it appears in - our scene. - :param v: 3 coordinates (list, tuple, vec3) - """ - - if len(v) == 2: - print("Warning: Setting Entity velocity with a 2d vector.") - print("Vector:", v) - print("Entity:", self) - raise ValueError - - self._velocity = vec3(*v) + def velocity(self, value): + assert value == value + self._velocity = value def remove(self): if not self.removed: - for slot in self.slots: - slot.disconnect() + # for slot in self.slots: + # slot.disconnect() self.slots = [] self.on_remove() + if self.slot: + # weird bug (?): + + # fail (1 pos but 2 given): + # self.scene.disconnect(self.slot): + + # fail: missing require pos 'slot' + # self.scene.disconnect() + + s = self.slot() + if s: + s.disconnect() self.removed = True - self.scene.disconnect(self) + + # def disconnect(self): + # self.remove() # NOTE: Implementing the below method automatically registers event listener # So it's commented out. It still works as before. @@ -93,50 +184,138 @@ def remove(self): # return False + def play_sound(self, filename, callback=None, *args): + """ + Play sound with filename. + Triggers callback when sound is done + Forwards *args to channel.play() + """ + if filename in self.sounds: + self.sounds[filename][1].stop() + del self.sounds[filename] + + filename = path.join(SOUNDS_DIR, filename) + sound = self.app.load(filename, lambda: pygame.mixer.Sound(filename)) + if not sound: + return None, None, None + channel = pygame.mixer.find_channel() + if not channel: + return None, None, None + channel.set_volume(SOUND_VOLUME) + if callback: + slot = self.scene.when.once(self.sounds[0].get_length(), callback) + self.slots.add(slot) + else: + slot = None + self.sounds[filename] = (sound, channel, slot) + channel.play(sound, *args) + return sound, channel, slot + def update(self, dt): - if self._velocity: - self.position += self._velocity * dt # triggers position setter - if self._life is not None: - self._life -= dt - if self._life < 0: + # if len(self.slots) > 10: + # print(len(self.slots)) + + if self.ai: + self.ai.update(self, dt) + + if self.acceleration != vec3(0): + self.velocity += self.acceleration * dt + if self.velocity != vec3(0): + self.position += self.velocity * dt + + if self.life is not None: + self.life -= dt + if self.life <= 0: self.remove() + return + + if self.scripts: + self.scripts.each(lambda x, dt: x.update(dt), dt) + self.scripts.slots = list( + filter(lambda x: not x.get().done(), self.scripts.slots) + ) - def render(self, camera, surf=None): + if self.slots: + self.slots._slots = list( + filter(lambda slot: not slot.once or not slot.count, self.slots._slots) + ) + + self.on_update(dt) + + def render( + self, camera, surf=None, pos=None, scale=True, fade=True, cull=False, big=False + ): """ Tries to renders surface `surf` from camera perspective If `surf` is not provided, render self._surface (loaded from filename) """ - if not surf: - surf = self._surface - if not surf: + if not self.visible: + return + + if not pos: + pos = self.position + + pp = self.scene.player.position if self.scene.player else vec4(0) + if cull: + if pos.x < pp.x - 1000 or pos.x > pp.x + 1000: + self.remove() return - pos = camera.world_to_screen(self.position) - bottomleft = self.position + vec3(surf.get_width(), -surf.get_height(), 0) - pos_bl = camera.world_to_screen(bottomleft) + surf: SurfaceType = surf or self._surface + if not surf: + self.render_size = None + return + + half_diag = vec3(-surf.get_width(), surf.get_height(), 0) / 2 + world_half_diag = camera.rel_to_world(half_diag) - camera.position + + pos_tl = camera.world_to_screen(pos + world_half_diag) + pos_bl = camera.world_to_screen(pos - world_half_diag) - if None in (pos, pos_bl): + if None in (pos_tl, pos_bl): # behind the camera self.scene.remove(self) return - size = pos_bl.xy - pos.xy + size = ivec2(pos_bl.xy - pos_tl.xy) + self.render_size = size - max_fade_dist = camera.screen_dist * FULL_FOG_DISTANCE - fade = surf_fader(max_fade_dist, camera.distance(self.position)) + if not scale or 400 > size.x > 0 or big: + if scale: + # print(ivec2(size)) + surf = pygame.transform.scale(surf, ivec2(size)) - if size.x > 0: - surf = pygame.transform.scale(surf, ivec2(size)) + # don't fade close sprites + far = abs(pos.z - pp.z) > 1000 + if fade and far: + max_fade_dist = camera.screen_dist * FULL_FOG_DISTANCE + alpha = surf_fader(max_fade_dist, camera.distance(pos)) + # If fade is integer make it bright faster + alpha = clamp(int(alpha * fade), 0, 255) + if surf.get_flags() & pygame.SRCALPHA: + surf.fill((255, 255, 255, alpha), None, pygame.BLEND_RGBA_MULT) + else: + surf.set_alpha(alpha) + surf.set_colorkey(0) + # if not far: + # if not 'Rain' in str(self) and not 'Rock' in str(self): + # print('skipped fade', self) + self.app.screen.blit(surf, ivec2(pos_tl)) - surf.set_alpha(fade) - surf.set_colorkey(0) - self.app.screen.blit(surf, ivec2(pos)) + # if size.x > 150: + # self.scene.remove(self) - if size.x > 150: - self.scene.remove(self) + # def __del__(self): + # for slot in self.slots: + # slot.disconnect() + + # NOTE: Implementing the below method automatically sets up Script + + # def __call__(self): + # pass + + # NOTE: Implementing the below method automatically sets up collisions. - def __del__(self): - for slot in self.slots: - slot.disconnect() - self.slots = [] + # def collision(self, other, dt): + # pass diff --git a/game/base/inputs.py b/game/base/inputs.py new file mode 100644 index 0000000..b0d4e4f --- /dev/null +++ b/game/base/inputs.py @@ -0,0 +1,357 @@ +from dataclasses import dataclass +from typing import Dict, Union, Set + +import pygame + +from game.constants import DEBUG +from game.util import clamp +from game.base.signal import Signal + + +class ButtonInput: + def match(self, event) -> bool: + return False + + def update(self, event): + if self.match(event): + return self.pressed(event) + return None + + def pressed(self, event) -> bool: + """Whether a matching event is a press or a release""" + return False + + +@dataclass(frozen=True) +class KeyPress(ButtonInput): + key: int + + def match(self, event): + return event.type in (pygame.KEYDOWN, pygame.KEYUP) and event.key == self.key + + def pressed(self, event) -> bool: + """Whether a matching event is a press or a release""" + return event.type == pygame.KEYDOWN + + +@dataclass(frozen=True) +class JoyButton(ButtonInput): + joy_id: int + button: int + + def match(self, event): + return ( + event.type in (pygame.JOYBUTTONDOWN, pygame.JOYBUTTONUP) + and event.joy == self.joy_id + and event.button == self.button + ) + + def pressed(self, event): + """Whether a matching event is a press or a release""" + return event.type == pygame.JOYBUTTONDOWN + + +@dataclass(frozen=True) +class JoyAxisTrigger(ButtonInput): + joy_id: int + axis: int + threshold: int = 0.5 + above: bool = True + """Whether the button is pressed when the value is above or below the threshold""" + + def match(self, event) -> bool: + return ( + event.type == pygame.JOYAXISMOTION + and event.joy == self.joy_id + and event.axis == self.axis + ) + + def pressed(self, event) -> bool: + return self.above == (event.value > self.threshold) + + +@dataclass(frozen=True) +class JoyAxis: + joy_id: int + axis: int + reversed: bool = False + sensibility: float = 1.0 + threshold: float = 0.2 + + def match(self, event): + return ( + event.type == pygame.JOYAXISMOTION + and event.joy == self.joy_id + and event.axis == self.axis + ) + + def value(self, event): + """The value of a matching event.""" + + if abs(event.value) < self.threshold: + return 0 + + scaled = event.value * self.sensibility + if self.reversed: + return -scaled + else: + return scaled + + +class Button: + def __init__(self, *keys): + """ + A boolean input. + + :param keys: any number of keycodes or ButtonInputs + """ + + self._keys: Set[ButtonInput] = { + KeyPress(key) if isinstance(key, int) else key for key in keys + } + self._pressed = {} + self.just_released = False + self.just_pressed = False + self.just_double_pressed = False + + self._always = Signal() + self._on_press = Signal() + self._on_release = Signal() + self._on_double_press = Signal() + self._repeat = Signal() # _repeat[callback] = [delay, trigger_count] + + self.last_press = float("-inf") + """Time since last release of the button""" + self.press_time = 0 + self.dt = 0 # time since last frame + """ + Time the button has been pressed. + If it isn't pressed, it is the duration of the last press. + """ + + def update(self, dt): + """Trigger all callbacks and updates times""" + + self.last_press += dt + if self.pressed: + self.press_time += dt + + self.dt = dt + + self._always(self) + + if self.just_pressed: + self._on_press(self) + + if self.just_double_pressed: + self._on_double_press(self) + + if self.just_released: + self._on_release(self) + + if self.pressed: + self._repeat.blocked += 1 + for wref in self._repeat.slots: + c = wref() + if not c: + continue + if c.delay * c.repetitions <= self.press_time: + # It isn;t possible to set it directly, I don't know why + c.repetitions += 1 + c(self) + self._repeat.blocked -= 1 + self._repeat.refresh() + + def event(self, events): + self.just_pressed = False + self.just_double_pressed = False + self.just_released = False + + old_pressed = self.pressed + for event in events: + for key in self._keys: + if key.match(event): + self._pressed[key] = key.pressed(event) + + if not old_pressed: + if self.pressed: + self.press_time = 0 + self.just_pressed = True + if self.double_pressed: + self.just_double_pressed = True + else: + if not self.pressed: + # All keys were just released + self.last_press = 0 + self.just_released = True + for wref in self._repeat.slots: + c = wref() + if not c: + continue + c.repetitions = 0 + + @property + def pressed(self): + """Whether the button is actually pressed.""" + return sum(self._pressed.values(), 0) > 0 + + @property + def double_pressed(self): + """Whether the button was just double pressed""" + return self.pressed and self.last_press < 0.1 + + def always_call(self, callback): + return self._always.connect(callback) + + def on_press(self, callback): + return self._on_press.connect(callback) + + def on_release(self, callback): + return self._on_release.connect(callback) + + def on_double_press(self, callback): + return self._on_double_press.connect(callback) + + def on_press_repeated(self, callback, delay): + """ + Call `callback` when the button is pressed and + every `delay` seconds while it is pressed. + Note: the same function cannot be a repeat callback + for two different things. + """ + + slot = self._repeat.connect(callback) + slot.delay = delay + slot.repetitions = 0 + return slot + + def disconnect(self, callback): + """Remove a callback from all types if present.""" + if callback in self._always: + self._always.disconnect(callback) + if callback in self._on_press: + self._on_press.disconnect(callback) + if callback in self._on_release: + self._on_release.disconnect(callback) + if callback in self._on_double_press: + self._on_double_press.disconnect(callback) + if callback in self._repeat: + self._on_double_press.disconnect(callback) + + +class Axis: + def __init__(self, negative, positive, *axis, smooth=0.1): + """ + An input axis taking values between -1 and 1. + + Callbacks are disconnected with -= + :param negative: keycode or list of keycodes + :param positive: keycode or list of keycodes + :param axis: any number of JoyAxis + :param smooth: Duration (s) to smooth values + """ + + if isinstance(negative, int): + negative = [negative] + if isinstance(positive, int): + positive = [positive] + + self._negative = {KeyPress(n): False for n in negative} + self._positive = {KeyPress(p): False for p in positive} + self._axis = set(axis) + self._callbacks = Signal() + self._smooth = smooth + + self.non_zero_time = 0 + self.zero_time = 0 + + # Hold the number of keys pressed + self._int_value = 0 + # Hold the smoothed number of keys pressed + self._value = 0 + # Hold the total value of axis, + # separately because of different tracking methods + self._axis_value = 0 + + def __str__(self): + return f"Axis({self.value})" + + @property + def value(self): + return clamp(self._value + self._axis_value, -1, 1) + + def always_call(self, callback): + return self._callbacks.connect(callback) + + def __isub__(self, callback): + return self._callbacks.disconnect(callback) + + def update(self, dt): + """Trigger all callbacks and updates times""" + if self._int_value != 0: + # Nonzero check is okay as JoyAxis already count the threshold + self.non_zero_time += dt + self.zero_time = 0 + else: + self.non_zero_time = 0 + self.zero_time += dt + + if self._smooth <= 0: + self._value = self._int_value + else: + dv = dt / self._smooth + if self._int_value > 0: + self._value += dv + elif self._int_value < 0: + self._value -= dv + else: + if self._value > 0: + self._value -= dv + else: + self._value += dv + + if abs(self._value) <= dv: + # To have hard zeros + self._value = 0 + self._value = clamp(self._value, -1, 1) + + self._callbacks(self) + + def event(self, events): + axis_value = 0 + any_axis = False + for event in events: + for pos in self._positive: + if pos.match(event): + self._positive[pos] = pos.pressed(event) + for neg in self._negative: + if neg.match(event): + self._negative[neg] = neg.pressed(event) + + for axis in self._axis: + if axis.match(event): + # We take the most extreme value + val = axis.value(event) + if abs(val) > abs(axis_value): + axis_value = val + any_axis = True + + self._int_value = sum(self._positive.values()) - sum(self._negative.values()) + if any_axis: + self._axis_value = axis_value + + +class Inputs(dict, Dict[str, Union[Button, Axis]]): + def update(self, dt): + """Trigger all callbacks and updates times""" + for inp in self.values(): + inp.update(dt) + + def event(self, events): + """Actualize buttons and axis.""" + for inp in self.values(): + inp.event(events) + + if DEBUG: + for event in events: + print(event) diff --git a/game/base/script.py b/game/base/script.py new file mode 100644 index 0000000..4290479 --- /dev/null +++ b/game/base/script.py @@ -0,0 +1,240 @@ +#!/usr/bin/env python + +from game.base.signal import Slot +from game.base.when import When +from game.constants import * +from game.base.signal import Signal +from glm import vec3, vec4, ivec4 +import math +import importlib +import traceback + + +class Script: + def __init__(self, app, ctx, script, use_input=True, script_args=None): + self.app = app + self.ctx = ctx + self.when = When() + self.slots = [] + + self.paused = False + self.dt = 0 + self.fn = script + self.resume_condition = None + self.script_args = script_args + self.scripts = Signal() # extra scripts attached to this one + + # these are accumulated between yields + # this is different from get_pressed() + self.keys = set() + self.keys_down = set() + self.keys_up = set() + self.use_input = use_input + + if use_input: + self.event_slot = self.app.on_event.connect(self.event) + else: + self.event_slot = None + + # Is True while the script is not yielding + # Meaning if a script calls something, .inside is True during that call + # Useful for checking assert for script-only functions + self.inside = False + + self.script = script # (this calls script property) + + def push(self, fn): + print(fn) + if self.script_args: + script = Script(self.app, self.ctx, fn, self.use_input, *self.script_args) + else: + script = Script(self.app, self.ctx, fn, self.use_input) + + self.scripts += script + + def pause(self): + self.paused = True + + def resume(self): + self.paused = False + + def event(self, ev): + if ev.type == pygame.KEYDOWN: + self.keys_down.add(ev.key) + self.keys.add(ev.key) + elif ev.type == pygame.KEYUP: + self.keys_up.add(ev.key) + try: + self.keys.remove(ev.key) + except KeyError: + pass + + def running(self): + return self._script is not None + + def done(self): + return self._script is None + + def key(self, k): + # if we're in a script: return keys since last script yield + # assert self.script.inside + + assert self.inside # please only use this in scripts + assert self.event_slot # input needs to be enabled (default) + + if isinstance(k, str): + return ord(k) in self.keys + return k in self.keys + + def key_down(self, k): + # if we're in a script: return keys since last script yield + # assert self.script.inside + + assert self.inside # please only use this in scripts + assert self.event_slot # input needs to be enabled (default) + + if isinstance(k, str): + return ord(k) in self.keys_down + return k in self.keys_down + + def key_up(self, k): + # if we're in a script: return keys since last script yield + # assert self.script.inside + + assert self.inside # please only use this in scripts + assert self.event_slot # input needs to be enabled (default) + + if isinstance(k, str): + return ord(k) in self.keys_up + return k in self.keys_up + + # This makes scripting cleaner than checking script.keys directly + # We need these so scripts can do "keys = script.keys" + # and then call keys(), since it changes + # def keys(self): + # # return key downs since last script yield + # assert self.inside # please only use this in scripts + # assert self.event_slot # input needs to be enabled (default) + # return self._keys + + # def keys_up(self): + # # return key ups since last script yield + # assert self.inside # please only use this in scripts + # assert self.event_slot # input needs to be enabled (default) + # return self._key_up + + @property + def script(self): + return self._script + + @script.setter + def script(self, script=None): + # print("Script:", script, self.script_args) + self.slots = [] + self.paused = False + + if isinstance(script, str): + lib = importlib.import_module("game.scripts." + script) + run = False + if not hasattr(lib, "run"): + # no run method? look for cls + for name, cls in lib.__dict__.items(): + if name.startswith("Level"): + try: + int(name[len("Level") :]) + except ValueError: + continue # not a level number + if self.script_args: + self._script = iter(cls(*self.script_args, self)) + else: + self._script = iter(cls(self)) + break + else: + self.inside = True + if self.script_args: + self._script = run(*self.script_args, self) + else: + self._script = run(self) + self.inside = False + # self.locals = {} + # exec(open(path.join(SCRIPTS_DIR, script + ".py")).read(), globals(), self.locals) + + # if "run" not in self.locals: + # assert False + # self.inside = True + # self._script = self.locals["run"](self.app, self.ctx, self) + elif isinstance(script, type): + # So we can pass a Level class + if self.script_args: + self._script = iter(script(*self.script_args, self)) + else: + self._script = iter(script(self)) + elif callable(script): # function + if self.script_args: + self._script = script(*self.script_args, self) + else: + self._script = script(self) + elif script is None: + self._script = None + else: + raise TypeError + + def sleep(self, t): + return self.when.once(t, self.resume) + + def update(self, dt): + + # accumulate dt between yields + self.dt += dt + + self.when.update(dt) + + if self.resume_condition: + if self.resume_condition(): + self.resume() + + ran_script = False + # continue running script (until yield or end) + if self._script and not self.paused: + try: + + self.inside = True + slot = next(self._script) + ran_script = True + self.inside = False + + if isinstance(slot, Slot): + self.slots.append(slot) + self.pause() + elif slot: # func? + self.resume_condition = slot + if not self.resume_condition(): + self.pause() + else: + pass + + except StopIteration: + # print("Script Finished") + # traceback.print_exc() + self._script = None + # except Exception: + # traceback.print_exc() + # self._script = None + + # extra scripts + if self.scripts: + print("scripts") + self.scripts.each(lambda x, dt: x.update(dt), dt) + self.scripts.slots = list( + filter(lambda x: not x.get().done(), self.scripts.slots) + ) + + self.inside = False + + if ran_script: + # clear accumulated keys + self.key_down = set() + self.key_up = set() + self.dt = 0 + + return ran_script diff --git a/game/util/signal.py b/game/base/signal.py similarity index 58% rename from game/util/signal.py rename to game/base/signal.py index 17b4030..aaf804d 100755 --- a/game/util/signal.py +++ b/game/base/signal.py @@ -3,6 +3,47 @@ import weakref +class SlotList: + def __init__(self): + self._slots = [] + + def __str__(self): + return f"SlotList({self._slots}, len={len(self)})" + + def clear(self): + self._slots = [] + + def __bool__(self): + return bool(self._slots) + + def __len__(self): + return len(self._slots) + + def __iadd__(self, slot): + assert slot is not None + if isinstance(slot, (tuple, list)): + self._slots += slot + return self + self._slots.append(slot) + return self + + def __isub__(self, slot): + for i, slot in enumerate(self._slots): + if slot is slot: + del self._slots[i] + return self + return self + + # backwards compat with list + def append(self, slot): + assert slot is not None + self._slots.append(slot) + return self + + def __iter__(self): + return iter(self._slots) + + class Slot: def __init__(self, func, sig): self.func = func @@ -10,9 +51,14 @@ def __init__(self, func, sig): self.once = False self.count = 0 + def __str__(self): + return ( + f"Slot({self.func}, sig={self.sig}, once={self.once}, count={self.count})" + ) + def __call__(self, *args): func = self.func - if isinstance(self.func, weakref.ref): + if type(func) == weakref.ref: func = func() if not func: self.disconnect() @@ -25,7 +71,7 @@ def __call__(self, *args): def with_item(self, action, *args): func = self.func - if isinstance(func, weakref.ref): + if type(func) == weakref.ref: func = func() if not func: return None @@ -33,7 +79,7 @@ def with_item(self, action, *args): def with_slot(self, action, *args): func = self.func - if isinstance(func, weakref.ref): + if type(func) == weakref.ref: func = func() if not func: return None @@ -47,7 +93,7 @@ def disconnect(self): def get(self): func = self.func - if isinstance(func, weakref.ref): + if type(func) == weakref.ref: func = func() return func @@ -56,11 +102,18 @@ def __del__(self): class Signal: - def __init__(self): + def __init__(self, *args, **kwargs): + def noop(s): + return s + + self.adapter = args[0] if args else noop self.slots = [] self.blocked = 0 self.queued = [] + def __str__(self): + return f"Signal({self.slots}, queued={self.queued}, blocked={self.blocked}, adpated={self.adapter}, nb_slots={len(self)}, nb_queue={len(self.queued)})" + def __len__(self): return len(self.slots) @@ -68,7 +121,7 @@ def __call__(self, *args): self.blocked += 1 for slot in self.slots: - if isinstance(slot, weakref.ref): + if type(slot) == weakref.ref: wref = slot slot = wref() if slot is None: @@ -77,50 +130,83 @@ def __call__(self, *args): slot(*args) self.blocked -= 1 - self.refresh() + self.clean() - def refresh(self): + def clean(self): if self.blocked == 0: + for wref in self.slots: + if type(wref) == weakref.ref: + slot = wref() + if not slot: + self.disconnect(wref) + elif type(slot.func) == weakref.ref: + wfunc = slot.func() + if not wfunc: + self.disconnect(wref) + for func in self.queued: func() self.queued = [] + def refresh(self): # old name + self.clean() + def each(self, func, *args): if self.blocked: - self.queued.append(lambda func=func: self.each(func, *args)) + self.queued.append(lambda func=func, args=args: self.each(func, *args)) return None self.blocked += 1 for s in self.slots: + if type(s) == weakref.ref: + wref = s + s = wref() + if not func: + self.disconnect(wref) + continue s.with_item(func, *args) self.blocked -= 1 - self.refresh() + self.clean() def each_slot(self, func, *args): if self.blocked: - self.queued.append(lambda func=func: self.each_slot(func, *args)) + self.queued.append(lambda func=func, args=args: self.each_slot(func, *args)) return None self.blocked += 1 for s in self.slots: - if isinstance(s, weakref.ref): + if type(s) == weakref.ref: s = s() if not s: continue s.with_slot(func, *args) self.blocked -= 1 - self.refresh() + self.clean() + + def __iadd__(self, func): + self.connect(func, weak=False) + return self + + def __isub__(self, func): + self.disconnect(func) + return self def connect(self, func, weak=True, once=False): + if isinstance(func, (list, tuple)): + r = [] + for f in func: + r.append(self.connect(f, weak, once)) + return r + if self.blocked: # if we're blocked, then queue the call if isinstance(func, Slot): slot = func else: - slot = Slot(func, self) + slot = Slot(self.adapter(func), self) slot.once = once wslot = weakref.ref(slot) if weak else slot self.queued.append(lambda wslot=wslot: self.slots.append(wslot)) @@ -134,7 +220,7 @@ def connect(self, func, weak=True, once=False): return slot # make slot from func - slot = Slot(func, self) + slot = Slot(self.adapter(func), self) slot.once = once wslot = weakref.ref(slot) if weak else slot self.slots.append(wslot) @@ -145,7 +231,7 @@ def once(self, func, weak=True): def disconnect(self, slot): if self.blocked: - self.queued.append(lambda: self.disconnect(slot)) + self.queued.append(lambda slot=slot: self.disconnect(slot)) return None if isinstance(slot, weakref.ref): @@ -163,7 +249,7 @@ def disconnect(self, slot): elif isinstance(slot, Slot): for i in range(len(self.slots)): islot = self.slots[i] - if isinstance(islot, weakref.ref): + if type(islot) == weakref.ref: islot = islot() if not islot: return True @@ -179,12 +265,13 @@ def disconnect(self, slot): # delete by slot value value = slot for i in range(len(self.slots)): - func = self.slots[i].func - # if isinstance(value, weakref.ref): - # wref = slot - # = value() - # if not slot: - # return self.disconnect(wref) + slot = self.slots[i] + if type(slot) == weakref.ref: + wref = slot + slot = slot() + if not slot: + return self.disconnect(wref) + func = slot.func # func = slot.func # if isinstance(func, weakref.ref): # wref = func @@ -197,6 +284,22 @@ def disconnect(self, slot): return False + def clear_type(self, Type): + self.blocked += 1 + for slot in self.slots: + if isinstance(slot.get(), Type): + slot.disconnect() + self.blocked -= 1 + self.clean() + + def filter(self, func): + self.blocked += 1 + for slot in self.slots: + if func(slot.get()): + slot.disconnect() + self.blocked -= 1 + self.clean() + def clear(self): if self.blocked: self.queued.append(lambda: self.clear()) diff --git a/game/base/state.py b/game/base/state.py index af286c7..9eaa473 100755 --- a/game/base/state.py +++ b/game/base/state.py @@ -1,13 +1,36 @@ #!/usr/bin/env python +from game.base.signal import Signal +from game.base.script import Script + class State: - def __init__(self, app, state=None): + def __init__(self, app, state=None, script=None, **kwargs): self.app = app self.state = state # parent state + script = kwargs.get("script") + + self.scripts = Signal(lambda fn: Script(self.app, self, fn)) + + self.script = None + + if isinstance(script, str): + # load script from string 'scripts/' folder + self.script = script + self.scripts += self.script + + if callable(self): + # use __call__ as script + self.script = self + self.scripts += self def update(self, dt): - pass + + if self.scripts: + self.scripts.each(lambda x, dt: x.update(dt), dt) + self.scripts.slots = list( + filter(lambda x: not x.get().done(), self.scripts.slots) + ) def render(self): pass diff --git a/game/base/stats.py b/game/base/stats.py new file mode 100644 index 0000000..4c4db78 --- /dev/null +++ b/game/base/stats.py @@ -0,0 +1,14 @@ +#!/usr/bin/env python + +# persistent data across states for player, stored in app.data['stats'] + + +class Stats: + def __init__(self): + self.score = 0 + self.damage_done = 0 + self.damage_taken = 0 + self.level = 1 + self.lives = 1 # remaining + self.deaths = 0 + self.kills = 0 diff --git a/game/util/when.py b/game/base/when.py similarity index 68% rename from game/util/when.py rename to game/base/when.py index 0572e9d..5e766c9 100755 --- a/game/util/when.py +++ b/game/base/when.py @@ -1,8 +1,9 @@ #!/usr/bin/env python import weakref -from game.util.signal import Signal +from game.base.signal import Signal from game.constants import EPSILON +from game.util import map_range class When(Signal): @@ -20,10 +21,10 @@ def update_slot(self, slot, dt): slot = slot() if not slot: if isinstance(self.sig, weakref.ref): - sig = sig() + sig = self.sig() if not sig: return - self.sig.disconnect(sig) + self.sig.disconnect(wref) return if slot.start_t != 0: # not infinite timer @@ -32,12 +33,22 @@ def update_slot(self, slot, dt): if slot.fade: slot.t = max(0.0, slot.t) p = 1.0 - (slot.t / slot.start_t) - slot(p) + slot( + map_range( + # apply easing functin + (slot.ease(p) if slot.ease else p), + (0.0, 1.0), # from range + slot.range_, # to range + ) + ) if slot.t < EPSILON: + if slot.fade_end: + slot.fade_end() slot.disconnect() # queued return else: # not a fade + i = 0 while slot.t < EPSILON: if not slot.once or slot.count == 0: slot() @@ -46,7 +57,8 @@ def update_slot(self, slot, dt): return if slot.start_t == 0: break - slot.t += slot.start_t # wrap + slot.t = max(0, slot.t + slot.start_t) # wrap + break def update(self, dt): """ @@ -68,19 +80,21 @@ def every(self, t, func, weak=True, once=False): slot.fade = False slot.ease = None slot.once = once + # slot.fade_end = None + # slot.range_ = None return slot def once(self, t, func, weak=True): - return self.every(t, func, weak, True) + return self.every(t, func, weak, once=True) - def fade(self, length, func, ease=None, weak=True): + def fade(self, length, range_, func, end_func=None, ease=None): """ Every frame, call function with fade value [0,1] fade value """ - # slot = super().once(func, weak) - # slot = super().connect(func, weak) - slot = self.every(0, func, weak) + slot = self.every(0, func) slot.start_t = slot.t = float(length) slot.fade = True - # slot.ease = ease + slot.fade_end = end_func + slot.range_ = range_ + slot.ease = ease return slot diff --git a/game/constants.py b/game/constants.py index 21ac5f7..9b54247 100755 --- a/game/constants.py +++ b/game/constants.py @@ -5,27 +5,57 @@ """ import os +import sys + import glm import pygame TOP_LEVEL_DIR = os.path.dirname(os.path.dirname(__file__)) ASSETS_DIR = os.path.join(TOP_LEVEL_DIR, "data") SPRITES_DIR = os.path.join(ASSETS_DIR, "sprites") +SCRIPTS_DIR = os.path.join(TOP_LEVEL_DIR, "game", "scripts") +SOUNDS_DIR = os.path.join(ASSETS_DIR, "sounds") +MUSIC_DIR = os.path.join(ASSETS_DIR, "music") +FONTS_DIR = ASSETS_DIR + +SOUND_VOLUME = 0.1 +MUSIC_VOLUME = 1.0 + +# Images should all be in the SPRITES_DIR +SHIP_IMAGE_PATH = "ship.png" +CROSSHAIR_IMAGE_PATH = "crosshair.png" +CROSSHAIR_GREEN_IMAGE_PATH = "crosshair_green.png" +BULLET_IMAGE_PATH = "bullet.png" +CLOUD_IMAGE_PATHS = [f"cloud{i}.png" for i in range(1, 8)] ORANGE = (255, 165, 0) GREEN = (141, 178, 85) -BACKGROUND = pygame.Color("lightblue") +GRAY = (100, 100, 100) +BACKGROUND = (77, 143, 172) # we're in "2d" so X and Y basis vectors should be 2d # 3d is optional in positions and velocities -X = glm.vec2(1, 0) -Y = glm.vec2(1, 0) +X = glm.vec3(1, 0, 0) +Y = glm.vec3(0, 1, 0) Z = glm.vec3(0, 0, 1) EPSILON = 0.0001 # for floating point comparisons -FULL_FOG_DISTANCE = 1.3 +GROUND_HEIGHT = -300 +PLAYER_SPEED = glm.vec3(150, 150, -400) +BULLET_SPEED = 15000 +LASER_SPEED = 45000 +BULLET_SIZE = 200 +BULLET_OFFSET = glm.vec3(0, -20, -300) +CAMERA_OFFSET = glm.vec3(0, 0, 300) +SCREEN_DIST = 3000 +FULL_FOG_DISTANCE = 1.5 """ Distance at which we see nothing behing the screen camera. If the screen is 1000px from the camera, everything at 1300px will be completely transparent. """ +AIM_MAX_DIST = 3500 # Knowing that butterflies spawn at SCREEN_DIST * FULL_FOG_DISTANCE +BUTTERFLY_MIN_SHOOT_DIST = 1500 +DEBUG = False +"""For abusing prints. Finding info will require grepping""" +ENEMY_BULLET_FACTOR = 1 / 6 diff --git a/game/entities/ai.py b/game/entities/ai.py new file mode 100644 index 0000000..6083c4a --- /dev/null +++ b/game/entities/ai.py @@ -0,0 +1,224 @@ +from math import cos, sin, pi, acos +from random import uniform + +import glm +from glm import vec3, normalize, length, sign + +from game.constants import ( + BUTTERFLY_MIN_SHOOT_DIST, + BULLET_IMAGE_PATH, + DEBUG, + ENEMY_BULLET_FACTOR, + BULLET_SPEED, +) +from game.entities.bullet import Bullet + + +class AI: + sets_velocity = False + + def __call__(self, entity): + """Use it to set initial conditions from the entity""" + return self + + def update(self, entity, dt): + pass + + +class CircleAi(AI): + """Make an Entity turn in circle around it origin point.""" + + sets_velocity = True + + def __init__(self, radius, start_angle=0, angular_speed=2): + self.radius = radius + self.angular_speed = angular_speed + self.start_angle = start_angle + + def __call__(self, entity): + entity.ai_angle = self.start_angle + entity.ai_start_pos = vec3(entity.position) + entity.position += ( + vec3(cos(self.start_angle), sin(self.start_angle), 0) * self.radius + ) + return self + + def update(self, entity, dt): + if not entity.alive: + return + + # entity.ai_angle += self.angular_speed * dt + # entity.ai_angle %= pi * 2 + + # We recompute to better handle other AIs + d = (entity.position - entity.ai_start_pos).xy + r = length(d) + d /= r + + entity.ai_angle = acos(d.x) * sign(d.y) + dt * self.angular_speed / 2 + entity.velocity = ( + vec3(-sin(entity.ai_angle), cos(entity.ai_angle), 0) + * self.angular_speed + * r + ) + + # if DEBUG: + # print(entity, entity.ai_angle) + # print(entity.velocity / self.radius) + + +class ChasingAi(AI): + sets_velocity = True + + def __init__(self, speed=20): + if type(speed) == int or type(speed) == float: + self.speed = speed + else: + self.speed = 20 + + def update(self, entity, dt): + if not entity.alive: + return + player = entity.app.state.player # Assume state is Game + dir = player.position - entity.position + dir.z = 0 + if dir != vec3(0): + if abs(dir.x) < 40 and abs(dir.y) < 40: + # Too close, go away + entity.velocity = normalize(dir) * self.speed + else: + # Far get closer + entity.velocity = normalize(dir) * self.speed + + +class AvoidAi(AI): + sets_velocity = True + + def __init__(self, speed=20, radius=40): + self.radius = radius + self.speed = speed + + def update(self, entity, dt): + if not entity.alive: + return + + player = entity.app.state.player # Assume state is Game + dir = player.position - entity.position + dir.z = 0 + if dir != vec3(0) and length(dir.xy) < self.radius: + # Too close, go away + entity.velocity = -vec3(normalize(dir.xy), 0) * self.speed + else: + entity.velocity = vec3(0) + + +class RandomFireAi(AI): + def __init__(self, min_delay=1, max_delay=5): + self.min_delay = min_delay + self.max_delay = max_delay + + def __call__(self, entity): + entity.ai_next_fire = uniform(self.min_delay, self.max_delay) + return self + + def update(self, entity, dt): + entity.ai_next_fire -= dt + + if entity.ai_next_fire > 0 or not entity.alive: + return + + entity.ai_next_fire = uniform(self.min_delay, self.max_delay) + + player = entity.app.state.player + if player and player.alive: + # print('randomly fire') + to_player = player.position - entity.position + if BUTTERFLY_MIN_SHOOT_DIST < glm.length(to_player): + entity.play_sound("squeak.wav") + bu = entity.scene.add( + Bullet( + entity.app, + entity.scene, + entity, + entity.position, + to_player, + entity.damage, + BULLET_IMAGE_PATH, + ENEMY_BULLET_FACTOR * BULLET_SPEED, + ) + ) + # bu.speed *= ENEMY_BULLET_FACTOR + # bu.velocity *= ENEMY_BULLET_FACTOR + + +class RandomChargeAI(AI): + sets_velocity = True + + def __init__(self, aggressivity=3): + """ + The entity charges randomly at the player. + :param aggressivity: Aggressivity between 1 and 10 + + Please don't do 10 of aggressivity ;p + """ + self.aggressivity = aggressivity + + def random_charge(self): + d = (4 / self.aggressivity) ** 2 + return uniform(d * 0.5, d * 1.5) + + def __call__(self, entity): + entity.ia_next_charge = self.random_charge() + entity.ai_charge_time = 0 + return self + + def update(self, entity, dt): + if entity.ai_charge_time > 0: + entity.ai_charge_time -= dt + player = entity.app.state.player + to_player = player.position - entity.position + + if to_player.z > 0: + # Butterfly is behind the player + return + + entity.play_sound("squeak.wav") + entity.velocity = glm.normalize(to_player) * 30 * self.aggressivity + entity.ia_next_charge = self.random_charge() + else: + entity.ia_next_charge -= dt + entity.velocity = vec3(0) + + if entity.ia_next_charge < 0: + entity.ai_charge_time = self.aggressivity ** 2 / 15 + 1 + + +class CombinedAi(AI): + def __init__(self, *ais): + """ + Combines multiple ais. + No checks are made to prevent interferences. + """ + + self.ais = [ai for ai in ais if ai is not None] + + def __call__(self, entity): + for ai in self.ais: + ai(entity) + return self + + @property + def sets_velocity(self): + return any(ai.sets_velocity for ai in self.ais) + + def update(self, entity, dt): + # Sum velocities if movement AIs + vel = vec3(0) + for ai in self.ais: + ai.update(entity, dt) + if ai.sets_velocity: + vel += entity.velocity + if self.sets_velocity: + entity.velocity = vel + if DEBUG: + print("Combined", len(self.ais), "AIs") diff --git a/game/entities/blast.py b/game/entities/blast.py new file mode 100644 index 0000000..bc8c5b7 --- /dev/null +++ b/game/entities/blast.py @@ -0,0 +1,95 @@ +#!/usr/bin/python + +import random + +import pygame +from glm import ivec2, ivec4, vec3 + +from game.base.entity import Entity +from game.base.being import Being +from game.util import * +from game.constants import * + + +class Blast(Entity): + """ + A visual blast radius from Buttabomber + """ + + def __init__(self, app, scene, radius, color="white", damage=1, spread=1, **kwargs): + super().__init__(app, scene, **kwargs) + + self.app = app + self.scene = scene + self.color = ncolor(color) + self.radius = radius + self.damage = damage # 1dmg/sec + self.spread = spread + self.parent = None + + if self.damage: + self.play_sound("explosion.wav") + + self.collision_size = self.size = vec3(radius) + self.font_size = ivec2(24, 24) + font_fn = "data/PressStart2P-Regular.ttf" + + self.font = self.app.load( + font_fn + ":" + str(self.font_size.y), + lambda: pygame.font.Font(font_fn, self.font_size.y, bold=True), + ) + self.solid = True + + # self.play_sound("hurt.wav") + + def update(self, t): + super().update(t) + self.radius += t * self.spread + + def collision(self, other, dt): + from game.entities.player import Player + + # enemy vs player or player vs enemy? + if isinstance(other, Player): + # warning: this would trigger every frame if player didn't have blink + other.hurt(self.damage, self, self.parent) + + def render(self, camera): + if not self.visible: + return + + pos = self.position + + half_diag = vec3(-self.radius, self.radius, 0) + world_half_diag = camera.rel_to_world(half_diag) - camera.position + + pos_tl = camera.world_to_screen(pos + world_half_diag) + pos_bl = camera.world_to_screen(pos - world_half_diag) + + if None in (pos_tl, pos_bl): + # behind the camera + self.scene.remove(self) + return + + size = ivec2(pos_bl.xy - pos_tl.xy) + + # max_fade_dist = camera.screen_dist * FULL_FOG_DISTANCE + # alpha = surf_fader(max_fade_dist, camera.distance(pos)) + + # self.app.screen.blit(surf, ivec2(pos_tl)) + + # col = glm.mix(ncolor(self.color), self.app.state.scene.sky_color, alpha) + + # rad = (camera.position.z - self.position.z)/1000 * self.radius + # if rad < 0: + # self.remove() + # return + + screen_pos = ivec2(pos_tl) + size + pygame.gfxdraw.filled_circle( + self.app.screen, + int(abs(screen_pos.x - size.x / 2)), + int(abs(screen_pos.y - size.y / 2)), + int(abs(size.x)), + pg_color(self.color), + ) diff --git a/game/entities/boss.py b/game/entities/boss.py new file mode 100644 index 0000000..e3d331e --- /dev/null +++ b/game/entities/boss.py @@ -0,0 +1,204 @@ +from os import path + +import pygame +import glm +from glm import ivec2 +import math + +from game.base.enemy import Enemy +from game.base.entity import Entity +from game.constants import Y, SPRITES_DIR, ORANGE, GRAY +from game.entities.ai import AI +from game.entities.butterfly import Butterfly +from game.entities.bullet import Bullet +from game.entities.buttabomber import ButtaBomber +from game.entities.flyer import Flyer +from game.entities.camera import Camera +from game.util import * +from game.constants import * + + +class Boss(Enemy): + NB_FRAMES = 1 + DEFAULT_SCALE = 5 + + def __init__( + self, app, scene, pos, color=ORANGE, scale=DEFAULT_SCALE, num=0, ai=None + ): + """ + :param app: our main App object + :param scene: Current scene (probably Game) + :param color: RGB tuple + :param scale: + """ + super().__init__(app, scene, position=pos, ai=ai) + + # self.scene.music = "butterfly2.mp3" + + self.num = num + self.frames = self.get_animation(color) + self._surface = self.frames[0] + + size = self.frames[0].get_size() + self.collision_size = self.size = vec3(*size, min(size)) + + # self.solid = False + self.time = 0 + self.frame = 0 + self.hp = 1000 + self.damage = 1 + + # drift slightly in X/Y plane + # self.velocity = nrand() + + self.scripts += [self.throw, self.approach] + + def get_animation(self, color="red"): + cache_id = ("buttabomber.gif:frames", color) + if cache_id in self.app.cache: + return self.app.cache[cache_id] + + color = pg_color(color) + + filename = path.join(SPRITES_DIR, "buttabomber.gif") + + # load an image if its not already in the cache, otherwise grab it + + self.app.cache[cache_id] = frames + return frames + + def get_animation(self, color): + filename = path.join(SPRITES_DIR, "buttabomber.gif") + + # load an image if its not already in the cache, otherwise grab it + # image: pygame.SurfaceType = self.app.load_img('BOSS') + if "BOSS" not in self.app.cache: + image = pygame.image.load(filename) + image = pygame.transform.scale(image, ivec2(1024)) + self.app.cache["BOSS"] = image + else: + image = self.app.cache["BOSS"] + + brighter = color + darker = pygame.Color("yellow") + very_darker = pygame.Color("gold") + + palette = [(1, 0, 1), (0, 0, 0), brighter, darker, very_darker] + + image.set_palette(palette) + image.set_colorkey((1, 0, 1)) # index 0 + + self.width = image.get_width() // self.NB_FRAMES + self.height = image.get_height() + + frames = [ + image.subsurface((self.width * i, 0, self.width, self.height)) + for i in range(self.NB_FRAMES) + ] + + self.width = image.get_width() // self.NB_FRAMES + self.height = image.get_height() + self.size = ivec2(image.get_size()) + self.render_size = ivec2(image.get_size()) + + return [image] + + def fall(self): + self.velocity = -Y * 100 + self.life = 2 # remove in 2 seconds + self.alive = False + + def kill(self, damage, bullet, player): + + if not self.alive: + return False + + # Boss will turn gray when killed + self.frames = self.get_animation(GRAY) + + self.scripts = [] + self.explode() + self.fall() + return True + + # def hurt(self, damage, bullet, player): + # return super().hurt(damage, bullet, player) + + def update(self, dt): + if not self.alive: + self.velocity.z = -1000 + super().update(dt) + return + + self.time += dt + + s = 300 + st = 1 + self.position.x = s * math.sin(self.time * st) + self.position.y = s * math.sin(self.time * st) / 3 + 150 + + super().update(dt) + + def render(self, camera: Camera): + + if self.position.z > camera.position.z: + self.remove() + return + + surf = self.frames[int(self.time + self.num) % self.NB_FRAMES] + super(Boss, self).render(camera, surf) + + def approach(self, script): + yield + + self.velocity = Z * 4000 + + while self.scene.player.alive: + yield script.sleep(0.2) + ppos = self.scene.player.position + v = ppos - self.position + d = glm.length(v) + if d < 4000: + # self.velocity = vec3( + # nrand(20), nrand(20), self.scene.player.velocity.z * nrand(1) + # ) + # self.position.z = self.scene.player.position.z# + math.sin(self.time) + self.velocity.z = self.scene.player.velocity.z + # while True: + # # self.position.z = math.sin(self.time) + # yield + + def render(self, camera): + super().render( + camera, + surf=self._surface, + pos=None, + scale=True, + # fade=False, + cull=False, + big=True, + ) + + def throw(self, script): + yield + + while self.scene.player.alive: + yield script.sleep(random.random() * 2) + assert self.scene.player + ppos = self.scene.player.position + v = ppos - self.position + r = random.randint(0, 3) + if r == 0: + self.scene.add( + ButtaBomber(self.app, self.scene, self.position, velocity=v) + ) + elif r == 1: + for x in range(5): + self.scene.add( + Flyer(self.app, self.scene, self.position, velocity=v) + ) + elif r == 2: + for x in range(2): + self.scene.add( + Butterfly(self.app, self.scene, self.position, velocity=v) + ) diff --git a/game/entities/bullet.py b/game/entities/bullet.py index 76526a9..4848c4e 100644 --- a/game/entities/bullet.py +++ b/game/entities/bullet.py @@ -1,17 +1,60 @@ -#!/usr/bin/env python +##!/usr/bin/env python # from .abstract.entity import Entity -from game.constants import * -from game.base.entity import Entity +from glm import normalize -# CURRENTLY UNUSED +from game.base.being import Being +from game.base.entity import Entity +from game.constants import * class Bullet(Entity): - def __init__(self, app, scene, position): - super().__init__(app, scene, "bullet.png") - self.position = position - self.velocity = -Z * 2000 - self.life = 1 + def __init__( + self, + app, + scene, + parent, + position, + direction, + damage=1, + img=BULLET_IMAGE_PATH, + speed=BULLET_SPEED, + **kwargs + ): + self.speed = speed + velocity = normalize(direction) * speed + super().__init__( + app, + scene, + BULLET_IMAGE_PATH, + position=position, + velocity=velocity, + life=SCREEN_DIST * FULL_FOG_DISTANCE * 1.2 / self.speed, + **kwargs + ) + self.damage = damage + self.solid = True + self.size.z = BULLET_SIZE # to prevent tunneling + self.parent = parent # whoever shot the bullet - def update(self, t): - super().update(t) + def collision(self, other, dt): + # enemy vs player or player vs enemy? + if isinstance(other, Being): + if self.parent.friendly != other.friendly: + other.hurt(self.damage, self, self.parent) # apply dmg + if not other.friendly: + # damage indicator + if not other.alive: + pass + # self.scene.add( + # Message( + # self.app, + # self.scene, + # "+" + str(dmg), + # "white", + # position=other.position, + # life=1, + # velocity=Y * 100, + # ) + # ) + # self.register_hit(dmg) + self.remove() # remove the bullet diff --git a/game/entities/buttabomber.py b/game/entities/buttabomber.py new file mode 100644 index 0000000..208d3ec --- /dev/null +++ b/game/entities/buttabomber.py @@ -0,0 +1,206 @@ +from os import path + +from game.base.enemy import Enemy +from game.constants import * +from game.entities.camera import Camera +from game.entities.blast import Blast +from game.util import * + + +class ButtaBomber(Enemy): + NB_FRAMES = 1 + DEFAULT_SCALE = 10 + + def __init__( + self, + app, + scene, + pos, + color=ORANGE, + scale=DEFAULT_SCALE, + num=0, + ai=None, + **kwargs + ): + """ + :param app: our main App object + :param scene: Current scene (probably Game) + :param color: RGB tuple + :param scale: + """ + super().__init__(app, scene, position=pos, ai=ai, **kwargs) + + self.num = num + self.frames = self.get_animation(color) + + size = self.frames[0].get_size() + self.collision_size = self.size = vec3(*size, min(size)) + + self.hp = 10 + self.speed = 100 + + self.time = 0 + self.frame = 0 + self.damage = 3 + + # drift slightly in X/Y plane + self.velocity = ( + vec3(random.random() - 0.5, random.random() - 0.5, 0) * random.random() * 2 + ) + + self.scripts += [self.injured, self.approach] + + def get_animation(self, color="red"): + cache_id = ("buttabomber.gif:frames", color) + if cache_id in self.app.cache: + return self.app.cache[cache_id] + + color = pg_color(color) + + filename = path.join(SPRITES_DIR, "buttabomber.gif") + + # load an image if its not already in the cache, otherwise grab it + image: pygame.SurfaceType = self.app.load_img(filename) + + brighter = color + darker = pygame.Color("darkred") + very_darker = pygame.Color("black") + + palette = [(1, 0, 1), (0, 0, 0), brighter, darker, very_darker] + + image.set_palette(palette) + image.set_colorkey((1, 0, 1)) # index 0 + + self.width = image.get_width() // self.NB_FRAMES + self.height = image.get_height() + + frames = [ + image.subsurface((self.width * i, 0, self.width, self.height)) + for i in range(self.NB_FRAMES) + ] + + self.app.cache[cache_id] = frames + return frames + + # def fall(self): + # self.frames = self.get_animation(pygame.Color("gray")) + # self.velocity = -Y * 100 + # self.life = 2 # remove in 2 seconds + # self.alive = False + + def blast(self): + self.scripts.clear() + self.frames = self.get_animation(GRAY) + self.scene.add( + Blast( + self.app, + self.scene, + 2, # radius + "white", + 1, # damage + 200, # spread + position=self.position, + velocity=self.velocity, + life=0.2, + ), + ) + self.remove() + + def kill(self, damage, bullet, player): + + self.remove() + + if not self.alive: + return False + + return True + + def hurt(self, damage, bullet, player): + self.injured = True + self.blast() + self.position += 100 + # bullet.remove() + return super().hurt(damage, bullet, player) + + def update(self, dt): + self.time += dt + super().update(dt) + + def injured(self, script): + self.injured = False + yield + while self.alive: + if self.injured: + player = self.app.state.player + if player and player.alive: + to_player = glm.normalize(player.position - self.position) + self.velocity = vec3(nrand(100), nrand(100), 0) + self.velocity += to_player * 100 + + # ppos = self.scene.player.position + # pvel = self.scene.player.velocity + # v = ppos - self.position + # d = glm.length(v) + # self.velocity = to_player * nrand(20) + + for x in range(100): + self.frames = self.get_animation("yellow") + yield script.sleep(0.1) + self.frames = self.get_animation("purple") + yield script.sleep(0.1) + self.blast() + return + yield + + # def charge(self, script): + # """ + # Behavior script: Charge towards player randomly + # """ + # yield # no call during entity ctor + + # while True: + # # print('charge') + + # player = self.app.state.player + # if player and player.alive: + # to_player = player.position - self.position + # if glm.length(to_player) < 3000: # wihin range + # to_player = player.position - self.position + # self.velocity = glm.normalize(to_player) * 200 + # yield + + def render(self, camera: Camera): + + if self.position.z > camera.position.z: + self.remove() + return + + surf = self.frames[int(self.time + self.num) % self.NB_FRAMES] + super(ButtaBomber, self).render(camera, surf) + + def __call__(self, script): + self.activated = False + yield + while True: + for color in ["darkred", "white"]: + if not self.injured: + # if self.activated: + self.frames = self.get_animation(color) + yield script.sleep(0.25) + yield + + def approach(self, script): + yield + + self.velocity = Z * 4000 + + while True: + yield script.sleep(0.2) + ppos = self.scene.player.position + v = ppos - self.position + d = glm.length(v) + if d < 2500: + self.velocity = vec3( + nrand(20), nrand(20), self.scene.player.velocity.z * nrand(1) + ) + break diff --git a/game/entities/butterfly.py b/game/entities/butterfly.py index fcf46db..285cec8 100755 --- a/game/entities/butterfly.py +++ b/game/entities/butterfly.py @@ -1,44 +1,52 @@ from os import path -import pygame -from glm import ivec2, vec3 - -from game.base.entity import Entity - -from game.constants import SPRITES_DIR, ORANGE, FULL_FOG_DISTANCE +from game.base.enemy import Enemy +from game.constants import * from game.entities.camera import Camera -from game.util.util import * +from game.util import * -class Butterfly(Entity): +class Butterfly(Enemy): NB_FRAMES = 4 DEFAULT_SCALE = 5 - def __init__(self, app, scene, pos, color=ORANGE, scale=DEFAULT_SCALE, num=0): + def __init__( + self, + app, + scene, + pos, + color=ORANGE, + scale=DEFAULT_SCALE, + num=0, + ai=None, + **kwargs + ): """ :param app: our main App object :param scene: Current scene (probably Game) :param color: RGB tuple :param scale: """ - - super().__init__(app, scene) + super().__init__(app, scene, position=pos, ai=ai, **kwargs) self.num = num - self.frames = self.get_animation(color) - self.position = pos + + size = self.frames[0].get_size() + self.collision_size = self.size = vec3(*size, min(size)) self.time = 0 self.frame = 0 + self.damage = 1 def get_animation(self, color): - fn = path.join(SPRITES_DIR, "butterfly-orange.png") + + filename = path.join(SPRITES_DIR, "butterfly-orange.png") # load an image if its not already in the cache, otherwise grab it - image: pygame.SurfaceType = self.app.load(fn, lambda: pygame.image.load(fn)) + image: pygame.SurfaceType = self.app.load_img(filename) - h, s, v = rgb2hsv(*color) + h, s, v = rgb2hsv(color[0], color[1], color[2]) brighter = hsv2rgb(h + 0.03, s + 0.1, v + 0.1) darker = hsv2rgb(h - 0.06, s - 0.1, v - 0.1) very_darker = hsv2rgb(h + 0.2, 0.5, 0.2) @@ -58,33 +66,37 @@ def get_animation(self, color): return frames - def update(self, dt): - super().update(dt) - self.time += dt * 10 + def fall(self): + self.velocity = -Y * 100 + self.life = 2 # remove in 2 seconds + self.alive = False - def render(self, camera: Camera): - pos = camera.world_to_screen(self.position) - bottomleft = self.position + vec3(self.width, -self.height, 0) - pos_bl = camera.world_to_screen(bottomleft) + def kill(self, damage, bullet, player): - if None in (pos, pos_bl): - # behind the camera - self.scene.remove(self) - return + if not self.alive: + return False - size = pos_bl.xy - pos.xy + # Butterfly will turn gray when killed + self.frames = self.get_animation(GRAY) - max_fade_dist = camera.screen_dist * FULL_FOG_DISTANCE - fade = surf_fader(max_fade_dist, camera.distance(self.position)) + self.scripts = [] + self.explode() + self.play_sound("butterfly.wav") + self.fall() + return True - if size.x > 0: - self.surf = pygame.transform.scale( - self.frames[int(self.time + self.num) % self.NB_FRAMES], ivec2(size) - ) + # def hurt(self, damage, bullet, player): + # return super().hurt(damage, bullet, player) - self.surf.set_alpha(fade) - self.surf.set_colorkey(0) - self.app.screen.blit(self.surf, ivec2(pos)) + def update(self, dt): + self.time += dt * 10 + super().update(dt) + + def render(self, camera: Camera): + + if self.position.z > camera.position.z: + self.remove() + return - if size.x > 150: - self.scene.remove(self) + surf = self.frames[int(self.time + self.num) % self.NB_FRAMES] + super(Butterfly, self).render(camera, surf) diff --git a/game/entities/camera.py b/game/entities/camera.py index 828e42b..9c666bf 100755 --- a/game/entities/camera.py +++ b/game/entities/camera.py @@ -1,11 +1,13 @@ #!/usr/bin/env python - +from math import pi from typing import Union -from glm import dot, cross, vec3, vec2, normalize, rotate +import glm +from glm import dot, cross, vec3, vec2, normalize, rotate, identity, quat +from pygame import Vector3 from game.base.entity import Entity -from game.constants import EPSILON +from game.constants import EPSILON, SCREEN_DIST class Camera(Entity): @@ -23,7 +25,7 @@ def __init__( position: vec3 = None, direction: vec3 = None, up: vec3 = None, - screen_dist: float = 1000, + screen_dist: float = SCREEN_DIST, ): if up is None: up = vec3(0, 1, 0) @@ -62,18 +64,19 @@ def world_to_screen(self, world_pos: vec3) -> Union[vec2, None]: # distance along the screen's z axis dist = dot(rel, self.direction) - if dist < EPSILON: + if dist < 10: return None absolute_y = dot(rel, self.up) absolute_x = dot(rel, self.horizontal) + screen_size = vec2(self.screen_size) pos = ( vec2( absolute_x / dist * self.screen_dist, absolute_y / dist * self.screen_dist, ) - + self.screen_size / 2 + + screen_size / 2 ) pos.y = self.screen_size.y - pos.y @@ -107,7 +110,7 @@ def rotate_around_direction(self, angle): :param angle: (counterclockwise) rotation in radians """ - self.up = rotate(self.up, angle, self.direction) + self.up = self._rotate(self.up, angle, self.direction) def rotate_around_horizontal(self, angle): """ @@ -118,8 +121,8 @@ def rotate_around_horizontal(self, angle): """ horiz = self.horizontal - self.up = rotate(self.up, angle, horiz) - self.direction = rotate(self.direction, angle, horiz) + self.up = self._rotate(self.up, angle, horiz) + self.direction = self._rotate(self.direction, angle, horiz) def rotate_around_up(self, angle): """ @@ -129,4 +132,18 @@ def rotate_around_up(self, angle): :param angle: (counterclockwise) rotation in radians """ - self.direction = rotate(self.direction, angle, self.up) + self.direction = self._rotate(self.direction, angle, self.up) + + @staticmethod + def _rotate(vec, angle, axis): + """Rotate vec around axis by the given angle in radians.""" + + # We are using the Pygame's vectors here + # because pyglm documentation is SHIT and + # I can't find a way do do the same SIMPLE thing. + + vec = Vector3(vec) + axis = Vector3(axis) + vec.rotate_ip(angle * 180 / pi, axis) + + return vec3(*vec) diff --git a/game/entities/cloud.py b/game/entities/cloud.py new file mode 100644 index 0000000..dd3e1d0 --- /dev/null +++ b/game/entities/cloud.py @@ -0,0 +1,20 @@ +from random import randint, choice + +from glm import vec3 + +from game.base.entity import Entity +from game.constants import CLOUD_IMAGE_PATHS + + +class Cloud(Entity): + if randint(0, 10) <= 5: + hdg = -1 + else: + hdg = 1 + + def __init__(self, app, scene, pos: vec3, z_vel: float): + vel = vec3(randint(0, 15) * Cloud.hdg, 0, z_vel) + + super().__init__( + app, scene, choice(CLOUD_IMAGE_PATHS), position=pos, velocity=vel, scale=4 + ) diff --git a/game/entities/flyer.py b/game/entities/flyer.py new file mode 100644 index 0000000..76ef9a9 --- /dev/null +++ b/game/entities/flyer.py @@ -0,0 +1,130 @@ +from os import path +import glm + +from game.base.enemy import Enemy +from game.constants import * +from game.entities.camera import Camera +from game.entities.bullet import Bullet +from game.util import * + + +class Flyer(Enemy): + NB_FRAMES = 3 + DEFAULT_SCALE = 10 + + def __init__( + self, + app, + scene, + pos, + color=ORANGE, + scale=DEFAULT_SCALE, + num=0, + ai=None, + **kwargs + ): + """ + :param app: our main App object + :param scene: Current scene (probably Game) + :param color: RGB tuple + :param scale: + """ + super().__init__(app, scene, position=pos, ai=ai, **kwargs) + + self.hp = 15 + + self.time = 0 + self.frame = 0 + self.damage = 3 + self.speed = 100 + self.injured = False + + ppos = self.scene.player.position + self.velocity.x = ( + glm.sign(self.scene.player.position.x - self.position.x) * self.speed + ) + self.flipped = self.velocity.x < 0 + + self.velocity.y = nrand(20) + + self.num = num + self.frames = [ + self.get_animation(False), + self.get_animation(True), + ] + + size = self.frames[0][0].get_size() + self.collision_size = self.size = vec3(*size, min(size)) + + def load_flipped(self, fn): + img = pygame.image.load(os.path.join(SPRITES_DIR, fn)) + img = pygame.transform.flip(img, True, False) + return img + + def get_animation(self, injured=False): + + if injured: + filename = "flyer2.png" + else: + filename = "flyer.png" + + # load an image if its not already in the cache, otherwise grab it + + tag = ":+h" if self.flipped else "" + image = self.app.load(filename + tag, lambda: self.load_flipped(filename)) + + self.width = image.get_width() // self.NB_FRAMES + self.height = image.get_height() + + frames = [ + image.subsurface((self.width * i, 0, self.width, self.height)) + for i in range(self.NB_FRAMES) + ] + + return frames + + def kill(self, damage, bullet, player): + + if not self.alive: + return False + + self.explode() + self.remove() + return True + + def hurt(self, damage, bullet, player): + self.injured = True + return super().hurt(damage, bullet, player) + + def update(self, dt): + + self.time += dt + + super().update(dt) + + def render(self, camera: Camera): + + surf = self.frames[self.injured][ + int(self.time * 20 + self.num) % self.NB_FRAMES + ] + super(Flyer, self).render(camera, surf) + + def __call__(self, script): + yield + while self.alive: + yield script.sleep(1 + random.random() * 4) + + to_player = self.scene.player.position - self.position + if BUTTERFLY_MIN_SHOOT_DIST < glm.length(to_player): + self.play_sound("squeak.wav") + v = glm.mix(Z, to_player, 0.75) + self.scene.add( + Bullet( + self.app, + self.scene, + self, + self.position, + v, + speed=BULLET_SPEED * ENEMY_BULLET_FACTOR, + ) + ) diff --git a/game/entities/ground.py b/game/entities/ground.py index 03ca6b9..83ae979 100644 --- a/game/entities/ground.py +++ b/game/entities/ground.py @@ -1,17 +1,65 @@ +#!/usr/bin/env python +from functools import lru_cache + import pygame -from glm import vec3, normalize +import pygame.gfxdraw +from glm import vec3, vec4 +import glm +import random -from game.constants import FULL_FOG_DISTANCE, GREEN +from game.constants import FULL_FOG_DISTANCE, GREEN, EPSILON from game.entities.camera import Camera +from game.util import * from game.base.entity import Entity -from game.util.util import plane_intersection, line_segment_intersection class Ground(Entity): def __init__(self, app, scene, height): super().__init__(app, scene) self.position = vec3(0, height, float("-inf")) + self._color = pg_color(GREEN) + self.delay = 0.5 + self.delay_t = 0 # time until next redraw + self.color = GREEN + + def fade_opt(self, c): + """ + Sets color only if it hasn't change for self.delay seconds + """ + if self.delay_t > EPSILON: + return False + + self.delay_t = self.delay + + self.color = c + return True + + @property + def color(self): + return self._color + + @color.setter + def color(self, c): + self.texture = pygame.Surface(self.app.size / 8).convert() + width, height = self.texture.get_size() + + ground = self._color = pg_color(c) + sky = self.scene.sky_color or ncolor("blue") + + # Draw gradient + for y in range(height): + interp = (1 - y / height) * 2 + base = rgb_mix(ground, sky, interp) + pygame.draw.line(self.texture, base, (0, y), (width, y)) + + noise = noise_surf(self.texture.get_size(), random.randrange(5)) + self.texture.blit(noise, (0, 0)) + self.texture = pygame.transform.scale(self.texture, self.app.size) + + def update(self, t): + super().update(t) + self.delay_t = max(0, self.delay_t - t) def render(self, camera: Camera): super().render(camera) @@ -54,4 +102,4 @@ def render(self, camera: Camera): if len(poly) > 2: poly = [tuple(camera.world_to_screen(p)) for p in poly] - pygame.draw.polygon(self.app.screen, GREEN, poly) + pygame.gfxdraw.textured_polygon(self.app.screen, poly, self.texture, 0, 0) diff --git a/game/entities/message.py b/game/entities/message.py new file mode 100644 index 0000000..59c9a10 --- /dev/null +++ b/game/entities/message.py @@ -0,0 +1,63 @@ +#!/usr/bin/python + +import random + +import pygame +from glm import ivec2, ivec4, vec3 + +from game.base.entity import Entity +from game.util import * +from game.constants import * + + +class Message(Entity): + """ + A single message in world space + """ + + def __init__(self, app, scene, text, color, **kwargs): + super().__init__(app, scene, **kwargs) + + self.app = app + self.scene = scene + + self.collision_size = self.size = vec3(24 * len(text), 24, 150) + self.font_size = ivec2(24, 24) + + self.shadow_color = pygame.Color(120, 120, 120, 0) + self.shadow2_color = pygame.Color(0, 0, 0, 0) + + self.set(text, color) + + @property + def font_size(self): + return self._font_size + + @font_size.setter + def font_size(self, value): + self._font_size = value + + font_fn = "data/PressStart2P-Regular.ttf" + self.font = self.app.load( + font_fn + ":" + str(self.font_size.y), + lambda: pygame.font.Font(font_fn, self.font_size.y, bold=True), + ) + + def set(self, text, color): + self.text = text + self.size = vec3(24 * len(text), 24, 24) + self.color = pg_color(color) + + self.surfaces = [ + self.font.render(text, True, self.shadow2_color), + self.font.render(text, True, self.shadow_color), + self.font.render(text, True, self.color), + ] + + self.offsets = [vec3(2, -2, 0), vec3(-2, 3, 0), vec3(0, 0, 0)] + + def render(self, camera, surf=None, pos=None, scale=True, fade=True): + if not pos: + pos = self.position + for i, img in enumerate(self.surfaces): + super().render(camera, self.surfaces[i], pos + self.offsets[i], scale, fade) diff --git a/game/entities/player.py b/game/entities/player.py index 860ce31..ca36489 100755 --- a/game/entities/player.py +++ b/game/entities/player.py @@ -1,53 +1,432 @@ #!/usr/bin/python -import pygame -from glm import vec3 +import math +import random +from typing import List +from glm import ivec2, vec2, length, vec3 +from pygame.surface import SurfaceType + +from game.base.being import Being +from game.base.enemy import Enemy from game.base.entity import Entity +from game.base.inputs import Axis +from game.base.stats import Stats from game.constants import * from game.entities.bullet import Bullet +from game.entities.blast import Blast +from game.entities.boss import Boss +from game.entities.butterfly import Butterfly +from game.entities.message import Message +from game.entities.powerup import Powerup +from game.entities.weapons import Weapon, WEAPONS +from game.util import ncolor + + +class Player(Being): + def __init__(self, app, scene, speed=PLAYER_SPEED, level=0): + super().__init__(app, scene, filename=SHIP_IMAGE_PATH) + self.game_state = self.scene.state -# from game.entities.bullet import Bullet + # persistant stats for score screen + self.stats = self.app.data["stats"] = self.app.data.get("stats", Stats()) + self.scene.player = self -class Player(Entity): - def __init__(self, app, scene, speed): - super().__init__(app, scene) - self.score = 0 + self.max_hp = self.hp = 3 + self.friendly = True # determines what Beings you can damage + self.crosshair_surf: SurfaceType = app.load_img(CROSSHAIR_IMAGE_PATH, 3) + self.crosshair_surf_green = app.load_img(CROSSHAIR_GREEN_IMAGE_PATH, 3) + self.crosshair_scale = 1 - self.dirkeys = [ - # directions - pygame.K_LEFT, - pygame.K_RIGHT, - pygame.K_UP, - pygame.K_DOWN, + self.slots += [ + self.app.inputs["hmove"].always_call(self.set_vel_x), + self.app.inputs["vmove"].always_call(self.set_vel_y), + self.app.inputs["fire"].always_call(self.fire), + self.app.inputs["switch-gun"].on_press_repeated(self.next_gun, 0.5), + # self.app.inputs["test"].on_press(self.explode), ] - self.actionkeys = [pygame.K_RETURN, pygame.K_SPACE] - self.dir = [False] * len(self.dirkeys) - self.speed = speed - - def action(self, btn): - # print('shoot') - self.scene.add(Bullet(self.app, self.scene, position=self.position,)) - - def event(self, event): - if event.type == pygame.KEYUP or event.type == pygame.KEYDOWN: - for i, key in enumerate(self.dirkeys): - if key == event.key: - self.dir[i] = event.type == pygame.KEYDOWN - for i, key in enumerate(self.actionkeys): - if key == event.key: - if event.type == pygame.KEYDOWN: - self.action(0) - def update(self, dt): + self.position = vec3(0, 0, 0) + self.collision_size = vec3(50, 50, 500) + self.speed = vec3(speed) + self.velocity = vec3(self.speed) + + self.alive = True + self.solid = True + self.blinking = False + self.targeting = False + self.hide_stats = 0 + self.score_flash = 0.0 + self.weapon_flash = 0.0 + self.health_flash = 0.0 + + self.level = level + self.weapons: List[Weapon] = self.get_guns() + self.current_weapon = 0 + + self.scripts += [self.blink, self.smoke] + + @property + def targeting(self): + return self._targeting + + @targeting.setter + def targeting(self, t): + self._targeting = t + self.crosshair_t = 0 + + @property + def weapon(self): + return self.weapons[self.current_weapon % len(self.weapons)] + + @property + def score(self): + return self.stats.score + + @score.setter + def score(self, s): + self.stats.score = s + self.score_flash = 1 + + # def flash_score(self, script): + # yield + # while True: + # if self.score_flash: + # for x in range(50): + # self.score_light = True + # yield script.sleep(.2) + # self.score_light = False + # yield script.sleep(.2) + # self.score_light = False + # yield + + def get_guns(self): + return [ + self.scene.add(gun(self.app, self.scene, self)) + for gun in WEAPONS + if gun.level <= self.level + ] + + def restart(self): + + self.hp = 3 + self.visible = True + self.alive = True + self.blinking = False + self.speed = vec3(PLAYER_SPEED) + self.clear_scripts() + self.scripts += [self.blink, self.smoke] + + for wpn in self.weapons: + wpn.remove() + + self.weapons: List[Weapon] = self.get_guns() + + self.current_weapon = 0 + self.app.state.terminal.clear() + + self.app.state.restart() + + def kill(self, damage, bullet, enemy): + # TODO: player death + # self.scene.play_sound('explosion.wav') + # self.acceleration = -Y * 100 + self.hp = 0 + self.explode() + # self.remove() + self.visible = False + self.alive = False + self.stats.deaths += 1 + self.app.state.terminal.write_center("Oops! Try Again!", 10, "red") + # restart game in 2 seconds + self.scene.slotlist += self.scene.when.once(2, lambda: self.restart()) + return False + + def hurt(self, damage, bullet, enemy): + """ + Take damage from an object `bullet` shot by enemy + """ + + if self.hp <= 0: + return 0 + if self.blinking or not self.alive: + return 0 + + dmg = super().hurt(damage, bullet, enemy) + # self.scene.add(Message(self.app, self.scene, letter, position=pos)) + if dmg: + self.blinking = True + self.health_flash = 1 + return dmg + + # damage = min(self.hp, damage) # calc effective damage (not more than hp) + # self.hp -= damage + # self.blinking = True + # if self.hp <= 0: + # self.kill(damage, bullet, enemy) # kill self + # # if self.hp < 3: + # # self.smoke_event = scene.when.every(1, self.smoke) + # return damage + + def collision(self, other, dt): + if isinstance(other, Enemy) and not isinstance(other, Boss): + if other.alive: + self.hurt(other.hp, None, other) + other.kill(other.hp, None, self) + elif isinstance(other, Powerup): + if other.heart: + self.hp = self.max_hp + else: + for i, wpn in enumerate(self.weapons): + if wpn.letter == other.letter: + wpn.ammo = wpn.max_ammo + self.current_weapon = i + break + # print("powerup") + self.play_sound("powerup.wav") + other.solid = False + other.remove() + + def find_enemy_in_crosshair(self): + # Assuming state is Game + camera = self.app.state.camera + screen_center = vec2(camera.screen_size) / 2 + crosshair_radius = self.crosshair_surf.get_width() / 2 + + # Entities are sorted from far to close and we want the closest + for entity in reversed(self.scene.slots): + entity = entity.get() + if ( + isinstance(entity, Enemy) + and camera.distance(entity.position) < AIM_MAX_DIST + ): + center = camera.world_to_screen(entity.position) + if ( + center + and length(center - screen_center) + < crosshair_radius + entity.render_size.x / 2 + ): + return entity + + def write_weapon_stats(self): + if not self.alive: + return + + if not self.hide_stats: + ty = 0 + ofs = ivec2(0, 10) + + terminal = self.app.state.terminal + + wpn = self.weapons[self.current_weapon] + # extra space here to clear terminal + + if wpn.max_ammo < 0: + ammo = wpn.letter + " ∞" + else: + ammo = f"{wpn.letter} {wpn.ammo}/{wpn.max_ammo}" - self.velocity = ( - vec3( - -self.dir[0] + self.dir[1], - -self.dir[3] + self.dir[2], - -1, # always going forwards + if len(ammo) < 10: + ammo += " " * (10 - len(ammo)) # pad + + col = glm.mix(ncolor(wpn.color), ncolor("white"), self.weapon_flash) + self.game_state.terminal.write(" ", (1, ty), col) + self.game_state.terminal.write(ammo, (1, ty), col, ofs) + + col = glm.mix(ncolor("red"), ncolor("white"), self.health_flash) + # self.game_state.terminal.write( + # " " + "♥" * self.hp + " " * (3 - self.hp), 1, "red" + # ) + self.game_state.terminal.write_center(" ", ty + 1, col) + self.game_state.terminal.write_center(" ", ty, col) + self.game_state.terminal.write_center( + "♥" * self.hp + " " * (self.hp - self.max_hp), ty, "red", ofs ) - * self.speed - ) + + # Render Player's Score + score_display = "Score: {}".format(self.stats.score) + score_pos = ( + terminal.size.x - len(score_display) - 1, + ty, + ) + col = glm.mix(ncolor("white"), ncolor("yellow"), self.score_flash) + self.game_state.terminal.write(" ", score_pos + ivec2(0, 1), col) + self.game_state.terminal.write(score_display, score_pos, col, ofs) + + # self.game_state.terminal.write("WPN " + wpn.letter, (0,20), wpn.color) + # if wpn.max_ammo == -1: + # self.game_state.terminal.write("AMMO " + str(wpn.ammo) + " ", (0,21), wpn.color) + # else: + # self.game_state.terminal.write("AMMO n/a ", (0,21), wpn.color) + else: + self.game_state.terminal.clear(0) + + def next_gun(self, btn): # FIXME + # switch weapon + self.weapon_flash = 1 + self.current_weapon = (self.current_weapon + 1) % len(self.weapons) + while self.weapon.ammo == 0: + self.current_weapon = (self.current_weapon + 1) % len(self.weapons) + self.play_sound("powerup.wav") + + def set_vel_x(self, axis: Axis): + if not self.alive: + return + self.velocity.x = axis.value * self.speed.x + + def set_vel_y(self, axis: Axis): + if not self.alive: + return + self.velocity.y = axis.value * self.speed.y + + def find_aim(self): + camera = self.app.state.camera + butt = self.find_enemy_in_crosshair() + if butt is None: + aim = camera.rel_to_world(vec3(0, 0, -camera.screen_dist)) + else: + aim = butt.position + + return aim + + def fire(self, button): + if not self.alive: + return + + if not button.pressed: + return + + # no ammo? switch to default + if not self.weapon.ammo: + self.current_weapon = 0 + + if self.weapon.fire(self.find_aim()): + self.weapon_flash = 1 + self.play_sound(self.weapon.sound) + + def update(self, dt): + + if self.position.y <= -299: + # too low ? + self.velocity.y = max(0, self.velocity.y) + self.position.y = -299 + elif self.position.y >= 300: + # too high ? + self.velocity.y = min(0, self.velocity.y) + self.position.y = 300 + + if not self.alive: + self.velocity.x = 0 + self.velocity.y = 0 + + if self.targeting: + self.crosshair_t = (self.crosshair_t + dt) % 1 + self.crosshair_scale = 1 + 0.05 * math.sin(self.crosshair_t * math.tau * 2) + + self.score_flash = self.score_flash - dt + self.weapon_flash = self.weapon_flash - dt + self.health_flash = self.health_flash - dt super().update(dt) + + def smoke(self, script): + while self.alive: + if self.hp < 3: + self.scene.add( + Entity( + self.app, + self.scene, + "smoke.png", + position=self.position + vec3(0, -20, 0), + velocity=( + vec3(random.random(), random.random(), random.random()) + - vec3(0.5) + ) + * 2, + life=0.2, + particle=True, + ) + ) + yield script.sleep(self.hp) + yield + + # def engine(self, script): + # while self.alive: + # self.scene.add( + # Entity( + # self.app, + # self.scene, + # "smoke.png", + # position=self.position + vec3(0, -20, 0), + # velocity=( + # vec3(random.random(), random.random(), random.random()) + # - vec3(0.5) + # ) + # * 2, + # life=0.2, + # particle=True, + # ) + # ) + # yield script.sleep(0.2) + + def blink(self, script): + self.blinking = False + while self.alive: + if self.blinking: + for i in range(10): + self.visible = not self.visible + yield script.sleep(0.1) + self.visible = True + self.blinking = False + yield + + # def flash_stats(self, script): + # self.stats_visible = True + # for x in range(10): + # self.stats_visible = not self.stats_visible + # yield script.sleep(.1) + # self.stats_visible = True + + def render(self, camera): + self.write_weapon_stats() + + # Ship + rect = self._surface.get_rect() + + rect.center = (self.app.size[0] / 2, self.app.size[1] * 0.8) + direction = self.velocity.xy / self.speed.xy + rect.center += direction * (10, -10) + + if self.visible: + # stretch player graphic + sz = ivec2(*self._surface.get_size()) + + img = self._surface + if self.velocity: + sz.y += self.velocity.y / self.speed.y * 10 + img = pygame.transform.scale(self._surface, sz) + + if self.velocity.x: + rot = -self.velocity.x / self.speed.x * 30 + img = pygame.transform.rotate(img, rot) + + nrect = (rect[0], rect[1], *sz) + self.app.screen.blit(img, nrect) + + # Crosshair + if self.alive: + rect = self.crosshair_surf.get_rect() + rect.center = self.app.size / 2 + + if self.find_enemy_in_crosshair(): + if not self.targeting: + self.targeting = True # triggers + sz = ivec2(vec2(rect[2], rect[3]) * self.crosshair_scale) + img = pygame.transform.scale(self.crosshair_surf_green, sz) + rect[2] -= round(sz.x / 2) + rect[3] -= round(sz.y / 2) + self.app.screen.blit(img, rect) + else: + if self.targeting: + self.targeting = False # triggers + self.app.screen.blit(self.crosshair_surf, rect) diff --git a/game/entities/powerup.py b/game/entities/powerup.py new file mode 100644 index 0000000..61a96b1 --- /dev/null +++ b/game/entities/powerup.py @@ -0,0 +1,85 @@ +#!/usr/bin/python + +import pygame +from glm import vec3, ivec2 +import random +import math + +from game.entities.message import Message +from game.entities.weapons import WEAPONS +from game.base.entity import Entity +from game.constants import * + + +class Powerup(Message): + def __init__(self, app, scene, letter, **kwargs): + self.letter = letter + color = None + + if self.letter == "heart": + self.letter = "♥" + if self.letter == "star": + self.letter = "*" + if letter is None: # random powerup + # no default weapon and add hearts + powerups = list(w.letter for w in WEAPONS[1:]) + ["♥"] + self.letter = random.choice(powerups) + + self.heart = self.letter == "♥" + self.star = self.letter == "*" + + # get color of item + if self.heart: + color = pygame.Color("red") + elif self.star: + color = pygame.Color("white") + + else: + for wpn in WEAPONS: + if self.letter == wpn.letter: + color = pygame.Color(wpn.color) + break + assert color + + super().__init__(app, scene, self.letter, color, **kwargs) + self.velocity.z = 100 + + self.solid = True + + self.size = (10, 10) # About the same as the butterlies + self.collision_size = vec3(100, 100, 300) + self.time = 0 + self.offset = vec3(0) + self.velocity.z = 100 + + self.velocity.z = 100 + + def __call__(self, script): + color = self.color + while True: + self.set(self.letter, "gray") + yield script.sleep(0.2) + self.set(self.letter, color) + yield script.sleep(0.2) + + def update(self, dt): + super().update(dt) + self.time += dt + self.offset.y = math.sin(self.time * math.tau) + + def render(self, camera): + half_diag = vec3(-self.size[0], self.size[1], 0) / 2 + world_half_diag = camera.rel_to_world(half_diag) - camera.position + + pos_tl = camera.world_to_screen(self.position + world_half_diag) + pos_bl = camera.world_to_screen(self.position - world_half_diag) + + if None in (pos_tl, pos_bl): + # behind the camera + self.scene.remove(self) + return + + self.font_size = ivec2(pos_bl.xy - pos_tl.xy) / 2 + + # fade = 2 == twice bright + super().render(camera, None, self.position + self.offset, fade=2) diff --git a/game/entities/rain.py b/game/entities/rain.py new file mode 100644 index 0000000..ded340f --- /dev/null +++ b/game/entities/rain.py @@ -0,0 +1,36 @@ +from random import randint, choice + +import glm +from glm import vec2, vec3, ivec2 +import pygame +import random + +from game.base.entity import Entity +from game.constants import CLOUD_IMAGE_PATHS +from game.util import ncolor, random_rgb, pg_color +from game.constants import * + + +class Rain(Entity): + def __init__(self, app, scene, pos: vec3, z_vel: float, **kwargs): + + super().__init__(app, scene, None, position=pos, **kwargs) + + filled = "RAIN" in self.app.cache + + self._surface = self.app.load( + "RAIN", lambda: pygame.Surface(ivec2(2, 24)).convert() + ) + if not filled: + self._surface.fill(pg_color("lightgray")) + + self.velocity = vec3(0, -1000, 1000 + z_vel) + + def update(self, t): + if self.position.y < -300: + self.remove() + return + super().update(t) + + def render(self, camera): + return super().render(camera, scale=False, fade=False) diff --git a/game/entities/rock.py b/game/entities/rock.py new file mode 100644 index 0000000..25b5fd0 --- /dev/null +++ b/game/entities/rock.py @@ -0,0 +1,43 @@ +from random import randint, choice + +import glm +from glm import vec2, vec3, ivec2 +import pygame +import random + +from game.base.entity import Entity +from game.constants import CLOUD_IMAGE_PATHS +from game.util import ncolor, random_rgb, pg_color +from game.constants import * + + +class Rock(Entity): + def __init__(self, app, scene, pos: vec3, z_vel: float, **kwargs): + + super().__init__(app, scene, None, position=pos, velocity=Z * 1000, **kwargs) + + self._surface = None + gcolor = self.scene.ground_color + if gcolor: + + if "ROCK" not in self.app.cache: + # self.color = ncolor("white") + self._surface = pygame.Surface(ivec2(4)) + # self.color = ncolor("white") + self._surface.fill(pg_color(glm.mix(ncolor("black"), gcolor, 0.4))) + # self._surface.fill((0,0,0)) + + self.app.cache["ROCK"] = self._surface + + # self.velocity = Z * 10000 + z_vel + else: + self._surface = self.app.cache["ROCK"] + + def update(self, t): + if not self._surface or self.position.z > self.scene.player.position.z: + self.remove() + return + super().update(t) + + def render(self, camera): + return super().render(camera, fade=False) diff --git a/game/entities/ship.py b/game/entities/ship.py deleted file mode 100644 index d9cee31..0000000 --- a/game/entities/ship.py +++ /dev/null @@ -1,25 +0,0 @@ -from game.entities.player import Player -from game.constants import SPRITES_DIR -from glm import vec3, sign -import os -import pygame - - -class Ship(Player): - def __init__(self, app, scene, speed=vec3(300, 300, 200)): - super().__init__(app, scene, speed) - path = os.path.join(SPRITES_DIR, "ship.png") - self.img = self.app.load(path, lambda: pygame.image.load(path)) - - self.position = vec3(self.app.size.x / 2, self.app.size.y - 100, 0) - - def render(self, camera): - scale = (100, 100) - transformed = pygame.transform.scale(self.img, scale) - rect = transformed.get_rect() - rect.center = (self.app.size[0] / 2, self.app.size[1] * 0.8) - - dir = sign(self.velocity.xy) - rect.center += dir * (10, 10) - - self.app.screen.blit(transformed, rect) diff --git a/game/entities/star.py b/game/entities/star.py new file mode 100644 index 0000000..2cd70f9 --- /dev/null +++ b/game/entities/star.py @@ -0,0 +1,36 @@ +from random import randint, choice + +from glm import vec2, vec3, ivec2 +import pygame +import random + +from game.base.entity import Entity +from game.constants import CLOUD_IMAGE_PATHS + + +class Star(Entity): + def __init__(self, app, scene, pos: vec3, z_vel: float): + vel = vec3(0, 0, z_vel) + + super().__init__(app, scene, None, position=pos, velocity=vel) + + self._surface = self.app.load( + "STAR", lambda: pygame.Surface(ivec2(4)).convert() + ).copy() + self._surface.fill((255, 255, 255)) + self._surface.set_alpha(50) + + def render(self, camera): + return super().render(camera, scale=False, fade=False) + + def __call__(self, script): + yield + while True: + self._surface.set_alpha(50) + yield script.sleep(random.uniform(1, 10)) + + if random.random() < 0.5: + self._surface.set_alpha(0) + else: + self._surface.set_alpha(120) + yield script.sleep(0.1) diff --git a/game/entities/terminal.py b/game/entities/terminal.py index 8e096d4..6d2a94b 100755 --- a/game/entities/terminal.py +++ b/game/entities/terminal.py @@ -3,40 +3,57 @@ import random import pygame -from glm import ivec2, ivec4 +from glm import ivec2, ivec4, vec4 from game.base.entity import Entity +from game.constants import FONTS_DIR +from game.util import ncolor, pg_color +from os import path + + +class Char: + def __init__( + self, text, imgs, pos=ivec2(0, 0), color=(255, 255, 255, 0), offset=ivec2(0, 0), + ): + self.imgs = imgs + self.text = text + self.pos = pos + self.color = color + self.offset = offset class Terminal(Entity): - def __init__(self, app, scene): + def __init__(self, app, scene, size=None): super().__init__(app, scene) self.app = app self.scene = scene - self.font_size = ivec2(24, 24) - font_fn = "data/PressStart2P-Regular.ttf" + self.font_size = ivec2(size or 24) + self.spacing = ivec2(0) + font_fn = path.join(FONTS_DIR, "PressStart2P-Regular.ttf") # load the font if its not already loaded (cacheble) # we're appending :16 to cache name since we may need to cache # different sizes in the future self.font = self.app.load( - font_fn + ":" + str(self.font_size.y), + (font_fn, self.font_size.y), lambda: pygame.font.Font(font_fn, self.font_size.y, bold=True), ) # terminal size in characters - self.size = app.size / self.font_size + self.size = app.size / (self.font_size + self.spacing) # dirty flags for lazy redrawing self.dirty = True self.dirty_line = [True] * self.size.y # dirty flags per line + self._offset = ivec2(0, 0) + # 2d array of pygame text objects - self.terminal = [] + self.chars = [] for y in range(self.size.y): - self.terminal.append([None] * self.size.x) + self.chars.append([None] * self.size.x) self.surface = pygame.Surface( self.app.size, pygame.SRCALPHA, 32 @@ -68,42 +85,157 @@ def clear(self, pos=None): # clear the character at pos (x,y) # we use indices instead of .x .y since pos could be tuple/list - self.terminal[pos[1]][pos[0]] = None + self.chars[pos[1]][pos[0]] = None self.dirty_line[pos[1]] = True self.dirty = True - def write(self, text, pos, color=(255, 255, 255, 0)): + def offset(self, pos=(0, 0), offset=None): + + if offset is None: + # no ofs parameter? move entire terminal by offset (stored in pos now) + self._offset = pos + self.dirty = True + return + + if isinstance(pos, int): # row + for i in range(self.size.x): + self.offset((pos, i), offset) + return + + try: + ch = self.chars[pos[1]][pos[0]] + except IndexError: + # outside of screen + # print(pos) + return + + # offset char at position + if ch: + self.write( + ch.text, ch.pos, ch.color, offset=offset, align=-1, length=0, + ) + + def write( + self, + text, + pos=(0, 0), + color=vec4(1, 1, 1, 0), + offset=(0, 0), + align=-1, + length=0, + ): + + if isinstance(pos, (int, float)): + pos = ivec2(0, pos) + else: + # if decimal number, proportional to terminal size + # if isinstance(pos[0], float): + # if 0 < pos[0] < 1 or 0 < pos[0] < 1: + # pos = ivec2(pos[0] * self.size[0], pos[1] * self.size[1]) + # else: + # pos = ivec2(pos[0], pos[1]) + # else: + pos = ivec2(pos[0], pos[1]) + + length = max(length, len(text)) + + # Do alignment (-1, 0, 1) + if align == 0: # center + return self.write( + text, (pos[0] - length / 2, pos[1]), color, offset, -1, length + ) + elif align == 1: # right + return self.write( + text, (pos[0] + length, pos[1]), color, offset, -1, length + ) + + assert align == -1 # left + + if "\n" in text: + lines = text.split("\n") + for i, line in enumerate(lines): + self.write( + text, ivec2(pos[0], pos[1] + i), color, offset, align, length + ) + return if len(text) > 1: # write more than 1 char? write chars 1 by 1 for i in range(len(text)): - self.write(text[i], (pos[0] + i, pos[1]), color) + self.write(text[i], (pos[0] + i, pos[1]), color, offset, -1, length) return # color string name - if isinstance(color, str): - color = pygame.Color(color) + if color is not None: + color = pg_color(color) + # note that this allows negative positioning try: - self.terminal[pos[1]][pos[0]] + self.chars[pos[1]][pos[0]] except IndexError: # outside of screen + # print(pos) return - self.terminal[pos[1]][pos[0]] = ( - self.font.render(text, True, color), - self.font.render(text, True, self.shadow_color), - self.font.render(text, True, self.shadow2_color), + self.chars[pos[1]][pos[0]] = Char( + text, + [ + self.font.render(text, True, color), + self.font.render(text, True, self.shadow_color), + self.font.render(text, True, self.shadow2_color), + ], + ivec2(*pos), + color, + ivec2(*offset), ) self.dirty_line[pos[1]] = True self.dirty = True + def write_center( + self, + text, + pos=0, + color=vec4(1, 1, 1, 0), + offset=(0, 0), + length=0, + char_offset=(0, 0), + ): + """ + write() to screen center X on row `pos` + + :param char_offset: Shift the text by this offset after centering + """ + if isinstance(pos, (int, float)): + # if pos is int, set col number + pos = ivec2(0, pos) + else: + pos = ivec2(pos[0], pos[1]) + + # print(pos) + pos.x -= self.size.x / 2 + pos += char_offset + return self.write(text, pos, color, offset, 0, length) + + def write_right(self, text, pos=0, color=vec4(1, 1, 1, 0), offset=(0, 0), length=0): + """ + write() to screen right side + """ + + if isinstance(pos, (int, float)): + # if pos is int, set row number + pos = ivec2(0, pos) + else: + pos = ivec2(pos[0], pos[1]) + + pos.x += self.size.x - 2 + return self.write(text, pos, color, offset, 1, len(text)) + def scramble(self): """ Randomly sets every character in terminal to random character and color """ - for y in range(len(self.terminal)): - for x in range(len(self.terminal[y])): + for y in range(self.size.y): + for x in range(self.size.x): col = ( random.randint(0, 255), random.randint(0, 255), @@ -121,7 +253,7 @@ def render(self, camera): # self.surface.fill((255,255,255,0), (0, 0, *self.app.size)) - for y in range(len(self.terminal)): + for y in range(len(self.chars)): if not self.dirty_line[y]: continue @@ -137,22 +269,26 @@ def render(self, camera): ), ) - for x in range(len(self.terminal[y])): - text = self.terminal[y][x] - if text: - # shadow + for y in range(len(self.chars)): + + if not self.dirty_line[y]: + continue + + for x in range(len(self.chars[y])): + ch = self.chars[y][x] + if ch: + ofs = self._offset + ch.offset + self.spacing / 2 + pos = ivec2(x, y) * self.font_size + ofs + pos.x = max(0, min(self.app.size.x, pos.x)) + pos.y = max(0, min(self.app.size.y, pos.y)) self.surface.blit( - text[1], - ivec2(x, y) * self.font_size + ivec2(2, -2), # offset + ch.imgs[1], pos + ivec2(2, -2), ) self.surface.blit( - text[2], - ivec2(x, y) * self.font_size + ivec2(-3, 3), # offset + ch.imgs[2], pos + ivec2(-3, 3), ) # text - self.surface.blit( - text[0], (x * self.font_size.x, y * self.font_size.y) - ) + self.surface.blit(ch.imgs[0], pos) self.dirty_line[y] = False self.dirty = False diff --git a/game/entities/weapons.py b/game/entities/weapons.py new file mode 100644 index 0000000..7a4ebd5 --- /dev/null +++ b/game/entities/weapons.py @@ -0,0 +1,242 @@ +import pygame +from glm import vec3, normalize, length, dot + +from game.base.enemy import Enemy +from game.base.entity import Entity +from game.constants import BULLET_OFFSET, BULLET_IMAGE_PATH, BULLET_SPEED, LASER_SPEED +from game.entities.bullet import Bullet +from game.util import debug_log_call, clamp + + +class Weapon(Entity): + speed = 4 + max_ammo = 20 + damage = 1 + level = 100 # Unlock level + + def __init__(self, app, scene, player): + """ + A generic weapon. + + :param player: The player holding the weapon + :param ammo: Max ammunition + :param speed: bullets per second + :param damage: damage per bullet + """ + super().__init__(app, scene, parent=player) + + # Start we zero ammo on unlock + if player.level == self.level: + self.ammo = 0 + else: + self.ammo = self.max_ammo # current ammo + self.cooldown = 1 / self.speed + + self.last_fire = float("inf") + + def update(self, dt): + super().update(dt) + + self.last_fire += dt + + def get_bullets(self, aim): + raise NotImplementedError + + def fire(self, aim): + """Fire if it can""" + + if self.last_fire < self.cooldown: + return False + if self.ammo == 0: + return False + + for bullet in self.get_bullets(aim): + self.scene.add(bullet) + + self.ammo -= 1 + self.last_fire = 0 + + return True + + +class Pistol(Weapon): + color = "yellow" + letter = "P" + max_ammo = -1 + sound = "shoot.wav" + speed = 10 + damage = 1 + level = -1 + + def get_bullets(self, aim): + camera = self.app.state.camera + start = camera.rel_to_world(BULLET_OFFSET) + direction = aim - start + + yield Bullet( + self.app, + self.scene, + self.parent, + start, + direction, + self.damage, + BULLET_IMAGE_PATH, + ) + + +class MachineGun(Weapon): + color = "orange" + letter = "M" + max_ammo = 99 + sound = "shoot.wav" + speed = 25 + damage = 1 + level = 2 + + def get_bullets(self, aim): + camera = self.app.state.camera + start1 = camera.rel_to_world(BULLET_OFFSET + vec3(8, 0, 0)) + start2 = camera.rel_to_world(BULLET_OFFSET + vec3(-8, 0, 0)) + + for start in (start1, start2): + yield Bullet( + self.app, + self.scene, + self.parent, + start, + aim - start, + self.damage, + BULLET_IMAGE_PATH, + ) + + +class Laser(Bullet): + def __init__(self, app, scene, parent, position, direction, length, color, damage): + super().__init__( + app, scene, parent, position, direction, damage, speed=LASER_SPEED + ) + self.color = pygame.Color(color) + self.size.z = length + + def render(self, camera): + p1 = camera.world_to_screen(self.position) + p2 = camera.world_to_screen( + self.position + normalize(self.velocity) * self.size.z + ) + + pygame.draw.line(self.app.screen, self.color, p1, p2, 4) + + +class LaserGun(Weapon): + letter = "L" + color = "red" + max_ammo = 42 + sound = "laser.wav" + speed = 8 + damage = 2 + level = 3 + + def get_bullets(self, aim): + camera = self.app.state.camera + start = camera.rel_to_world(BULLET_OFFSET) + direction = aim - start + + yield Laser( + self.app, + self.scene, + self.parent, + start, + direction, + 300, + "red", + self.damage, + ) + + +class TracingBullet(Bullet): + def __init__( + self, + app, + scene, + parent, + position, + direction, + damage, + img=BULLET_IMAGE_PATH, + speed=BULLET_SPEED, + **kwargs + ): + super().__init__( + app, scene, parent, position, direction, damage, img, speed, **kwargs + ) + + self.aim = self.find_aim() + self.initial_vel = vec3(self.velocity) + self.t = 0 + + def find_aim(self): + # Find closest enemy + dist = float("inf") + closest = None + for e in self.scene.iter_entities(Enemy): + if not e.alive or e.position.z > self.position.z: + continue + + dir = e.position - self.position + v = vec3(self.velocity.xy * 10, self.velocity.z) + d = vec3(dir.xy * 10, dir.z) + if abs(dot(normalize(v), normalize(d))) < 0.9: + # Angles are too different + continue + + d = length(e.position - self.position) + if 200 < d < dist: + dist = d + closest = e + return closest + + def update(self, dt): + if self.aim is None: + self.aim = self.find_aim() + self.t = 0 + self.initial_vel = vec3(self.velocity) + if self.aim is None: + return super().update(dt) + + if self.aim.position.z > self.position.z: + self.aim = None + return super().update(dt) + + self.t = clamp(self.t + dt * 5, 0, 1) + + vel = self.speed * normalize(self.aim.position - self.position) + self.velocity = vel * self.t + self.initial_vel * (1 - self.t) + + return super().update(dt) + + +class TracingGun(Weapon): + letter = "A" + color = "green" + max_ammo = 20 + sound = "shoot.wav" + speed = 3 + level = 4 + + def get_bullets(self, aim): + camera = self.app.state.camera + start = camera.rel_to_world(BULLET_OFFSET) + direction = aim - start + + yield TracingBullet( + self.app, + self.scene, + self.parent, + start, + direction, + self.damage, + BULLET_IMAGE_PATH, + ) + + +WEAPONS = [Pistol, MachineGun, LaserGun, TracingGun] diff --git a/game/level.py b/game/level.py deleted file mode 100644 index eba78c8..0000000 --- a/game/level.py +++ /dev/null @@ -1,84 +0,0 @@ -import random -from collections import namedtuple, deque -from math import cos, sin, pi - -Spawn = namedtuple("Spawn", ["x", "y", "time"]) - - -class Level: - def __init__(self, spawn: deque): - self.spawn = spawn - self.time = 0 - - def update(self, dt): - """ - Get the list of butterflies that should spawn this frame - :param dt: time of the frame - :return: list of y positions between -1 and 1 - """ - self.time += dt - - spawns = [] - while self.spawn and self.spawn[0].time < self.time: - s = self.spawn.popleft() - spawns.append((s.x, s.y)) - - return spawns - - def is_over(self): - return len(self.spawn) == 0 - - -class BaseLevelBuilder: - def __init__(self): - self._t = 0 - self._paused = False - self._spawns = deque() - - def spawn(self, x: float, y: float): - """ - Spawn a butterfly at position (x, y) at the current max depth - :param x: float between -1 and 1. 0 is horizontal center of the screen - :param y: float between -1 and 1. 0 is vertical center of the screen - """ - - self._spawns.append(Spawn(x, y, self._t)) - - def pause(self, duration): - """ - Spawn nothing for the given duration. - - Next spawn will be exactly after `duration` seconds. - """ - - self._t += duration - - def build(self): - level = Level(self._spawns) - # So that we can use the same builder multiple times - self.__init__() - return level - - def uniform(self, n, duration) -> Level: - """Spawn n random butterflies for in `duration` seconds""" - - for i in range(n): - self.spawn(random.uniform(-1, 1), random.uniform(-1, 1)) - self.pause(duration / n) - - return self.build() - - def wall(self, n) -> Level: - """Spawn n butterflies in an horizontal line""" - for i in range(n): - self.spawn(-1 + 2 * i / n, 0) - - return self.build() - - def circle(self, n, duration) -> Level: - for i in range(n): - angle = i / n * pi * 2 - self.spawn(cos(angle), sin(angle)) - self.pause(duration / n) - - return self.build() diff --git a/game/levels/level1.py b/game/levels/level1.py deleted file mode 100644 index ca10a59..0000000 --- a/game/levels/level1.py +++ /dev/null @@ -1,31 +0,0 @@ -#!/usr/bin/env python - -# Level Script - -# Use scene.when to schedule events. -# Yield when you want to wait until the next event. -# This is a generator. Using a busy loop will halt the game. - - -def script(app, scene, resume): - when = scene.when - - # when.fade(2, scene.sky_color, resume, weak=False) - - print("level") - yield when.once(0.3, resume) - - print("script") - yield when.once(0.3, resume) - - print("test") - yield when.once(0.3, resume) - - print("1") - yield when.once(0.3, resume) - - print("2") - yield when.once(0.3, resume) - - print("3") - yield when.once(0.3, resume) diff --git a/game/scene.py b/game/scene.py index 09d4d2a..b061084 100755 --- a/game/scene.py +++ b/game/scene.py @@ -1,46 +1,386 @@ #!/usr/bin/env python -import pygame import functools -from game.util.signal import Signal, Slot -from game.util.when import When + +import glm +import pygame + +from game.base.signal import Signal, Slot, SlotList +from game.base.when import When from os import path from pygame import Color +from game.constants import * +from glm import vec3, vec4, ivec4, ivec3 +from game.base.script import Script +from game import util +from game.util import ( + clamp, + ncolor, + pg_color, + rand_RGB, + rgb_mix, + noise_surf, + noise_surf_dense_bottom, +) +from game.entities.cloud import Cloud +from game.entities.rock import Rock +from game.entities.rain import Rain +from game.entities.star import Star +from game.entities.ground import Ground + +from random import randint +import math +import weakref +import random # key function to do depth sort z_compare = functools.cmp_to_key(lambda a, b: a.get().position.z - b.get().position.z) class Scene(Signal): - def __init__(self, app, script="level1"): + def __init__(self, app, state, script=None, script_args=None): super().__init__() + self.max_particles = 32 self.app = app + self.state = state self.when = When() - self.paused = False - self.script_slots = [] - self._sky_color = pygame.Color("lightblue") + self.slotlist = SlotList() + self._sky_color = None + self.ground = None + self._ground_color = None + self._script = None + self.scripts = Signal(lambda fn: Script(self.app, self, fn)) + self.lightning_slot = None + self.lightning_density = 0 + + self.player = None + + self.rock_slot = None + self.rain_slot = None + self.has_clouds = False + self.lowest_fps = 1000 + + self.on_render = Signal() + + # self.script_paused = False + # self.script_slots = [] + # self.stars_visible = 0 + + # star_density = 80 + # self.star_pos = [ + # (randint(0, 200), randint(0, 200)) for i in range(star_density) + # ] + + # color change delays when using opt funcs + self.delay_t = 0 + self.delay = 0.5 + self.time = 0 + + self.sky_color = None + # self.ground_color = GREEN self.dt = 0 - self.script_fn = script + self.sounds = {} + + # self.script_fn = script + # self.event_slot = self.app.on_event.connect(self.even) + + # self.script_resume_condition = None + + # The below wrapper is just to keep the interface the same with signal + # on_collision.connect -> on_collision_connect + # class CollisionSignal: + # pass + + # self.on_collision = CollisionSignal() + # self.on_collision.connect = self.on_collision_connect + # self.on_collision.once = self.on_collision_once + # self.on_collision.enter = self.on_collision_enter + # self.on_collision.leave = self.on_collision_leave + + self._music = None + + if script: + self.script = script # trigger setter + + self.when.every(1, self.stabilize, weak=False) + + def iter_entities(self, *types): + for slot in self.slots: + ent = slot.get() + if ent and isinstance(ent, types): + yield ent + + def cloudy(self): + if self.has_clouds: + return + + pv = self.player.velocity if self.player else vec3(0) + if hasattr(self.app.state, "player"): + velz = pv.z + else: + velz = 0 + for i in range(30): + x = randint(-3000, 3000) + y = randint(0, 300) + z = randint(-4000, -1300) + pos = vec3(x, y, z) + self.add(Cloud(self.app, self, pos, velz)) + + self.has_clouds = True + + def lightning_strike(self): + oldsky = vec4(self.sky_color) if self.sky_color else None + self.sky_color = "white" + self.when.once( + 0.1, lambda oldsky=oldsky: self.set_sky_color(oldsky), weak=False + ) + self.play_sound("lightning.wav") + + def lightning_script(self, script): + yield + while True: + if self.lowest_fps <= 25: + break + yield script.sleep(1) + yield script.sleep((1 / self.lightning_density) * random.random()) + self.lightning_strike() + + def lightning(self, density=0.5): + if density < EPSILON: + self.lightning_slot = None + return + + if self.lowest_fps <= 25: + return + + self.lightning_density = density + self.lightning_slot = self.scripts.connect(self.lightning_script) + + def add_rock(self): + + if self.lowest_fps <= 25: + return + + velz = self.player.velocity.z if self.player else vec3(0) + x = randint(-500, 500) + y = GROUND_HEIGHT - 15 + z = -4000 + ppos = self.player.position if self.player else vec3(0) + pos = vec3(ppos.x, 0, ppos.z) + vec3(x, y, z) + self.add(Rock(self.app, self, pos, velz)) + + def add_rain_drop(self): + velz = self.player.velocity.z if self.player else 0 + x = randint(-400, 400) + y = randint(0, 300) + z = randint(-3000, -2000) + ppos = self.player.position if self.player else vec3(0) + pos = vec3(ppos.x, 0, ppos.z) + vec3(x, y, z) + self.add(Rain(self.app, self, pos, velz, particle=True)) + + def rain(self, density=25): + if density: + self.rain_slot = self.when.every(1 / density, self.add_rain_drop) + else: + self.rain_slot = None + + def rocks(self, density=10): + if density: + self.rock_slot = self.when.every(1 / density, self.add_rock) + else: + self.rock_slot = None + + def stars(self): + if hasattr(self.app.state, "player"): + velz = self.player.velocity.z + else: + velz = 0 + + for i in range(50): + x = randint(-500, 500) + y = -200 + (random.random() ** 0.5 * 800) + z = -3000 + pos = vec3(x, y, z) + self.add(Star(self.app, self, pos, velz)) + + def draw_sky(self): + width, height = self.app.size / 8 + + self.sky = pygame.Surface((width, height)) + sky_color = self.sky_color or ncolor(pygame.Color("blue")) + sky_color = [255 * s for s in sky_color] + + # for y in range(height): + # interp = (1 - y / height) * 2 + # for x in range(width): + # rand = rand_RGB() + # color = rgb_mix(sky_color, rand, 0.02) + # c = (sk * 0.98 + rand * 0.02) / i**1.1 + + # if interp == 0: + # color = (255, 255, 255) + # else: + # color = [min(int(c / interp ** 1.1), 255) for c in color] + # self.sky.set_at((x, y), color) + + # Draw gradient + for y in range(height): + interp = (1 - y / height) * 2 + base = [min(int(c / interp ** 1.1), 255) for c in sky_color] + pygame.draw.line(self.sky, base, (0, y), (width, y)) + + noise = noise_surf_dense_bottom(self.sky.get_size(), random.randrange(5)) + self.sky.blit( + noise, (0, 0), None, + ) + self.sky = pygame.transform.scale(self.sky, self.app.size) + + # if self.stars_visible: + # self.draw_stars(self.sky, self.star_pos) - self.script = script # (this calls script() property) + self.sky = pygame.transform.scale(self.sky, self.app.size) + + # def draw_stars(self, surface, star_positions): + # size = 1 + # for pos in star_positions: + # star = pygame.Surface((size, size)) + # star.fill((255, 255, 255)) + # star.set_alpha(175) + # surface.blit(star, pos) + + def remove_sound(self, filename): + if filename in self.sounds: + self.sounds[filename][0].stop() + del self.sounds[filename] + return True + return False + + def ensure_sound(self, filename, callback=None, *args): + """ + Ensure a sound is playing. If it isn't, play it. + """ + if filename in self.sounds: + return None, None, None + return self.play_sound(filename, callback, *args) + + def play_sound(self, filename, callback=None, *args): + """ + Plays the sound with the given filename (relative to SOUNDS_DIR). + Returns sound, channel, and callback slot. + """ + + if filename in self.sounds: + self.sounds[filename][0].stop() + del self.sounds[filename] + + filename = path.join(SOUNDS_DIR, filename) + sound = self.app.load(filename, lambda: pygame.mixer.Sound(filename)) + if not sound: + return None, None, None + channel = pygame.mixer.find_channel() + if not channel: + return None, None, None + channel.set_volume(SOUND_VOLUME) + if callback: + self.when.once(self.sounds[0].get_length(), callback, weak=False) + else: + slot = None + self.sounds[filename] = (sound, channel, slot) + channel.play(sound, *args) + self.when.once(sound.get_length(), lambda: self.remove_sound(sound), weak=False) + return sound, channel, slot @property def script(self): - return self.script_fn + return self._script @script.setter - def script(self, script): - local = {} - exec(open(path.join("game/scripts/", script + ".py")).read(), globals(), local) - self._script = local["script"](self.app, self) + def script(self, fn): + self._script = ( + Script(self.app, self, fn, use_input=True, script_args=(self.app, self)) + if fn + else None + ) + + @property + def music(self): + return self._music + + @property + def ground_color(self): + return self._ground_color + + @ground_color.setter + def ground_color(self, color): + if color is None: + return - def sleep(self, t): - return self.when.once(t, self.resume) + self._ground_color = ncolor(color) + if not self.ground: + self.ground = self.add(Ground(self.app, self, GROUND_HEIGHT)) + + self.ground.color = color + if "ROCK" in self.app.cache: + del self.app.cache["ROCK"] # rocks need to reload their color + + @music.setter + def music(self, filename): + self._music = filename + if self._music: + pygame.mixer.music.load(path.join(MUSIC_DIR, filename)) + pygame.mixer.music.play(-1) + + def on_collision_connect(self, A, B, func, once=True): + """ + during collision (touching) + """ + pass + + def on_collision_once(self, A, B, func, once=True): + """ + trigger only once + """ + pass + + def on_collision_enter(self, A, B, func, once=True): + """ + trigger upon enter collision + """ + + pass + + def on_collision_leave(self, A, B, func, once=True): + """ + trigger upon leave collision + """ + pass + + # @property + # def script(self): + # return self.script_fn + + # @script.setter + # def script(self, script): + # print("Script:",script) + # if isinstance(script, str): + # local = {} + # exec(open(path.join(SCRIPTS_DIR, script + ".py")).read(), globals(), local) + # self._script = local["script"](self.app, self) + # elif isinstance(script, type): + # # So we can pass a Level class + # self._script = iter(script(self.app, self)) + # elif script is None: + # self._script = None + # else: + # raise TypeError + + # def sleep(self, t): + # return self.when.once(t, self.resume) def add(self, entity): - self.slots.append(self.connect(entity, weak=False)) - if hasattr(entity, "event"): - entity.slots.append(self.app.add_event_listener(entity)) + slot = self.connect(entity, weak=False) + entity.slot = weakref.ref(slot) + # self.slotlist += slot return entity @property @@ -49,50 +389,182 @@ def sky_color(self): @sky_color.setter def sky_color(self, c): - # if isinstance(c, float): - # c = int(c * 255) - # self._sky_color = Color(c, c, c, 0) - # return + self._sky_color = ncolor(c) if c else None + self.draw_sky() + # reset ground gradient (depend on sky color) + self.ground_color = self._ground_color - if isinstance(c, str): - self._sky_color = Color(c) - return + # for scripts to call when.fade(1, set_sky_color) + def set_sky_color(self, c): + self.sky_color = ncolor(c) if c else None + + def set_sky_color_opt(self, c): + """ + Optimized for fades. + """ + if self.delay_t > EPSILON: + return False + # print("delay") - self._sky_color = Color(*c) + self.delay_t = self.delay + + self._sky_color = ncolor(c) if c else None + if self._sky_color: + self.draw_sky() + # reset ground gradient (depend on sky color) + self.set_ground_color_opt(self.ground_color) + return True + + def set_ground_color(self, c): + self.ground_color = ncolor(c) if c else None + + def set_ground_color_opt(self, c): + """ + Optimized for fades. + """ + if not self.ground: + self.ground = self.add(Ground(self.app, self, GROUND_HEIGHT)) + if c: + self.ground.fade_opt(ncolor(c)) + else: + self.ground = None def remove(self, entity): - return self.disconnect(entity) + # self.slotlist -= entity + super().disconnect(entity) + + # def resume(self): + # self.script_paused = False + + def invalid_size(self, size): + """Checks component for 0 or NaNs""" + return any(c != c or abs(c) < EPSILON for c in size) + + def update_collisions(self, dt): + + # cause all scene operations to be queueed + + self.blocked += 1 + + for slot in self.slots: + a = slot.get() + # only check if a is solid + if not a or not a.solid: + continue + + if self.invalid_size(a.collision_size): + continue + + # for each slot, loop through each slot + for slot2 in self.slots: + b = slot2.get() + # only check if b is solid + if not b or not b.solid: + continue + if slot is not slot2: + if not a.has_collision and not b.has_collision: + continue + + if self.invalid_size(b.collision_size): + continue - def resume(self): - self.paused = False + a_min = a.position - a.collision_size / 2 + a_max = a.position + a.collision_size / 2 + b_min = b.position - b.collision_size / 2 + b_max = b.position + b.collision_size / 2 + col = not ( + b_min.x > a_max.x + or b_max.x < a_min.x + or b_min.y > a_max.y + or b_max.y < a_min.y + or b_min.z > a_max.z + or b_max.z < a_min.z + ) + if col: + if a.has_collision: + a.collision(b, dt) + if b.has_collision: + b.collision(a, dt) + self.blocked -= 1 + + # run pending slot queue + if self.blocked == 0: + self.refresh() + + def stabilize(self): + """" + Stablize FPS by setting max partilces + Called every second (see when.once(1, self.stabilize in init) + """ + + if self.app.fps > 1: + self.lowest_fps = min(self.app.fps, self.lowest_fps) + + if self.app.fps < 45: + self.max_particles = max(self.max_particles / 2, 4) + + if self.lowest_fps >= 60: + if self.app.fps > 120: + self.max_particles = min(self.max_particles * 2, 64) + + def filter_script(self, slot): + if isinstance(slot, weakref.ref): + wref = slot + slot = wref() + if not slot: + return False + if slot.get().done(): + return False + return True def update(self, dt): + self.time += dt + # print(self.time) + self.delay_t = max(0, self.delay_t - dt) + # do time-based events self.when.update(dt) - # scripts needs this - self.dt = dt + self.update_collisions(dt) + self.refresh() + + # main level script + if self._script: + self._script.update(dt) - # run script - if not self.paused: - try: - a = next(self._script) - self.script_slots.append(a) - except StopIteration: - print("Level Finished") - self.paused = True - # self.app.state = None # 'menu' + # extra scripts + if self.scripts: + self.scripts.each(lambda x, dt: x.update(dt), dt) + self.scripts.slots = list(filter(self.filter_script, self.scripts.slots)) # self.sort(lambda a, b: a.z < b.z) - self.slots = sorted(self.slots, key=z_compare) + # self.slots = sorted(self.slots, key=z_compare) + self.slots.sort(key=z_compare) + self.slots = list(filter(lambda x: not x.get().removed, self.slots)) # call update(dt) on each entity self.each(lambda x, dt: x.update(dt), dt) + # update particles (remove if too many) + self.blocked += 1 + particle_count = 0 + for i, slot in enumerate(reversed(self.slots)): + e = slot.get() + if e.particle: + particle_count += 1 + if particle_count >= self.max_particles: + slot.disconnect() + self.blocked -= 1 + self.clean() + def render(self, camera): # call render(camera) on all scene entities - self.app.screen.fill(self._sky_color) + + if self.sky_color is not None: + self.app.screen.blit(self.sky, (0, 0)) # call render on each entity self.each(lambda x: x.render(camera)) + + self.on_render(camera) diff --git a/game/scripts/__init__.py b/game/scripts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/game/scripts/boss.py b/game/scripts/boss.py new file mode 100644 index 0000000..f4e7c26 --- /dev/null +++ b/game/scripts/boss.py @@ -0,0 +1,39 @@ +from math import pi +from random import uniform + +from game.constants import GREEN +from game.entities.ai import RandomFireAi +from game.scripts.level import Level + +from game.entities.butterfly import Butterfly +from game.entities.buttabomber import ButtaBomber +from game.entities.flyer import Flyer +from game.entities.boss import Boss + + +class Level5(Level): + number = 5 + name = "The Butterflies Strike Back" + ground = "darkblue" + sky = "darkred" + music = "butterfly2.ogg" + + def __call__(self): + self.scene.cloudy() + self.scene.rocks() + self.scene.stars() + self.scene.rain() + + yield from super().__call__() + + self.spawn(0, 0, None, Boss) + + while True: + for slot in self.scene.slots: + e = slot.get() + if isinstance(e, Boss): + continue + yield script.sleep(0.5) + + yield self.huge_pause() + yield from self.slow_type("Well done !", 5, "green", clear=True) diff --git a/game/scripts/intro.py b/game/scripts/intro.py deleted file mode 100644 index f32a251..0000000 --- a/game/scripts/intro.py +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env python -import pygame.mixer - - -def script(app, scene): - terminal = app.state.terminal - - scene.sky_color = "black" - typ = pygame.mixer.Sound("data/sounds/type.wav") - - msg = "Welcome to Butterfly Destroyers!" - for i in range(len(msg)): - terminal.write(msg[i], (i, 0), "red") - typ.play() - yield scene.sleep(0.1) - - while True: - terminal.write("Press any key to continue", (0, 2), "white") - yield scene.sleep(0.2) - terminal.clear(2) - yield scene.sleep(0.2) - - keys = app.keys() - if any(keys): - break - - scene.sky_color = "white" - terminal.clear() diff --git a/game/scripts/level.py b/game/scripts/level.py new file mode 100644 index 0000000..f604619 --- /dev/null +++ b/game/scripts/level.py @@ -0,0 +1,337 @@ +from contextlib import contextmanager +from math import cos, sin, pi, tau + +from glm import vec3, ivec2, normalize, vec2 + +from game.constants import FULL_FOG_DISTANCE, GREEN, DEBUG +from game.entities.ai import CircleAi, CombinedAi +from game.entities.butterfly import Butterfly +from game.entities.buttabomber import ButtaBomber +from game.entities.flyer import Flyer +from game.entities.camera import Camera +from game.entities.powerup import Powerup +from game.scene import Scene +from game.util import random_color + + +class Level: + sky = "#59ABE3" + ground = GREEN + night_sky = "#00174A" + name = "A Level" + default_ai = None + + # Pause times + small = 1 + medium = 2 + big = 4 + huge = 10 + # velocities + angular_speed = 2 + speed = 60 + + def __init__(self, app, scene, script): + self.app = app + self.scene: Scene = scene + self.script = script + self.spawned = 0 + self._skip = False + self.faster = 1 + + @property + def terminal(self): + return self.app.state.terminal + + @contextmanager + def skip(self): + """ + Use this as a context manager to skip parts of a level with creating it. + + Only works when DEBUG is True + Exemple: + + self.pause(5) # This will happen + with self.skip(): + # This will not happen + self.circle(100, 100, delay=1) + # This neither + self.pause(1000) + self.spawn(0, 0) # This will happen + + """ + self._skip += DEBUG + yield + self._skip -= DEBUG + + @contextmanager + def set_faster(self, val): + old = self.faster + self.faster = val + yield + self.faster = old + + def spawn_powerup(self, letter: str = None, x: float = 0, y: float = 0): + """ + Spawn a powerup at position (x, y) at the current max depth + :param x: float between -1 and 1. 0 is horizontal center of the screen + :param y: float between -1 and 1. 0 is vertical center of the screen + :param letter: str powerup letter, None means random powerup + """ + + if self._skip: + return + + # Assuming the state is Game + camera: Camera = self.app.state.camera + pos = vec3( + x, y, camera.position.z - camera.screen_dist * FULL_FOG_DISTANCE + ) * vec3(*camera.screen_size / 2, 1) + + self.scene.add(Powerup(self.app, self.scene, letter, position=pos)) + + def spawn(self, x: float = 0, y: float = 0, ai=None, Type=Butterfly): + """ + Spawn a butterfly at position (x, y) at the current max depth + :param x: float between -1 and 1. 0 is horizontal center of the screen + :param y: float between -1 and 1. 0 is vertical center of the screen + """ + + if self._skip: + return + + ai = ai or self.default_ai + + # Assuming the state is Game + camera: Camera = self.app.state.camera + pos = vec3( + x, y, camera.position.z - camera.screen_dist * FULL_FOG_DISTANCE + ) * vec3(*camera.screen_size / 2, 1) + + butt = self.scene.add( + Type(self.app, self.scene, pos, random_color(), num=self.spawned, ai=ai) + ) + + if DEBUG: + print("Spawned", butt) + + self.spawned += 1 + return butt + + def pause(self, duration): + """ + Spawn nothing for the given duration. + + Next spawn will be exactly after `duration` seconds. + """ + + if self._skip: + duration = 0 + + return self.script.sleep(duration / self.faster) + + def small_pause(self): + return self.pause(self.small) + + def medium_pause(self): + return self.pause(self.medium) + + def big_pause(self): + return self.pause(self.big) + + def bigg_pause(self): + return self.pause((self.big + self.huge) / 2) + + def huge_pause(self): + return self.pause(self.huge) + + def engine_boost(self, mult): + self.app.state.player.speed.x *= mult + self.app.state.player.speed.y *= mult + + def square(self, c, ai=None, Type=Butterfly): + self.spawn(c, c, ai, Type) + self.spawn(c, -c, ai, Type) + self.spawn(-c, c, ai, Type) + self.spawn(-c, -c, ai, Type) + + def wall(self, qte_x, qte_y, w, h, ai=None, Type=Butterfly): + for i in range(qte_x): + for j in range(qte_y): + self.spawn( + (i / (qte_x - 1) - 0.5) * w, (j / (qte_y - 1) - 0.5) * h, ai, Type + ) + + def circle(self, n, radius, start_angle=0, ai=None, instant=False): + """Spawn n butterflies in a centered circle of given radius""" + + for i in range(n): + angle = i / n * tau + start_angle + self.spawn(radius * cos(angle), radius * sin(angle), ai) + if instant: + yield self.pause(0) + else: + yield self.small_pause() + + def rotating_circle( + self, + n, + radius, + speed_mult=1, + center=(0, 0), + simultaneous=True, + ai=None, + Type=Butterfly, + ): + """ + radius should be in PIXELS + """ + + ai = ai or self.default_ai + + aspeed = self.angular_speed * speed_mult + for i in range(n): + angle = i / n * 2 * pi + + a = CircleAi(radius, angle, aspeed) + self.spawn(center[0], center[1], CombinedAi(a, ai), Type) + + if simultaneous: + yield self.pause(0) + else: + yield self.small_pause() + + def v_shape(self, n, dir=(1, 0), ai=None, Type=Butterfly, faster=1): + dir = normalize(vec2(dir)) * 0.4 # *0.4 so it isn't too spread out + + self.spawn(0, 0) + yield self.small_pause() + for i in range(1, n): + self.spawn(*dir * i / n, ai, Type) + self.spawn(*dir * -i / n, ai, Type) + yield self.pause(self.small / faster) + + def rotating_v_shape( + self, n, start_angle=0, angular_mult=1, ai=None, Type=Butterfly + ): + if self._skip: + yield self.pause(0) + return + + angular_speed = self.angular_speed * angular_mult + ai = ai or self.default_ai + + self.spawn(0, 0) + yield self.small_pause() + angle = start_angle + for i in range(1, n): + # We sync the ai angles + ai1 = CircleAi(i * 20, angle, angular_speed) + ai2 = CircleAi(i * 20, angle + pi, angular_speed) + butt = self.spawn(0, 0, CombinedAi(ai, ai1), Type) + self.spawn(0, 0, CombinedAi(ai, ai2), Type) + yield self.small_pause() + angle = butt.ai_angle + + def slow_type( + self, text, line=5, color="white", delay=0.1, clear=False, blink=False + ): + if self._skip: + yield self.pause(0) + return + + for i, letter in enumerate(text): + self.terminal.write_center( + letter, line, color, char_offset=(i, 0), length=len(text) + ) + if letter != " ": + self.scene.play_sound("type.wav") + yield self.pause(delay) + + yield self.pause(delay * clear) + terminal = self.terminal + + left = ivec2((terminal.size.x - len(text)) / 2, line) + if clear: + for i, letter in enumerate(text): + terminal.clear(left + (i, 0)) + self.scene.play_sound("type.wav") + yield self.pause(delay / 4) + + def slow_type_lines( + self, text: str, start_line=5, color="white", delay=0.08, clear=True + ): + if self._skip: + yield self.pause(0) + return + + for i, line in enumerate(text.splitlines()): + yield from self.slow_type(line.strip(), start_line + i, color, delay) + + if clear: + for i, line in enumerate(text.splitlines()): + left = ivec2((self.terminal.size.x - len(line)) / 2, start_line + i) + for j in range(len(line)): + self.terminal.clear(left + (j, 0)) + self.scene.play_sound("type.wav") + yield self.pause(delay / 4) + + def combine(self, *gens): + """ + Combine the generators so they run at the same time. + This assumes they only yield pauses and at least one. + """ + + infinity = float("inf") + pauses = [0] * len(gens) + + while True: + for i, gen in enumerate(gens): + if pauses[i] == 0: + try: + pauses[i] = next(gen).t + except StopIteration: + pauses[i] = infinity + + m = min(pauses) + if m == infinity: + # They have all finished + return + + yield self.pause(m) + for i in range(len(pauses)): + pauses[i] -= m + + def __call__(self): + self.scene.sky_color = self.sky + self.scene.ground_color = self.ground + self.scene.music = self.music + + if self.name: + yield from self.slow_type(self.name, 6, "white", 0.1) + + terminal = self.app.state.terminal + terminal.clear(6) + + self.scene.play_sound("message.wav") + + # blink + for i in range(10): + # terminal.write(self.name, left, "green") + terminal.write_center("Level " + str(self.number), 4, "white") + terminal.write_center(self.name, 6, "green") + terminal.write_center("Go!", 8, "white") + yield self.pause(0.1) + + terminal.clear(8) + yield self.pause(0.1) + + for line in (4, 6, 8): + terminal.clear(line) + + left = ivec2((terminal.size.x - len(self.name)) / 2, 5) + for i in range(len(self.name)): + terminal.clear(left + (i, 0)) + yield self.pause(0.04) + + def __iter__(self): + return self() diff --git a/game/scripts/level0.py b/game/scripts/level0.py new file mode 100644 index 0000000..99a433b --- /dev/null +++ b/game/scripts/level0.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python + +# Level Script + +# Use scene.when to schedule events. +# Yield when you want to wait until the next event. +# This is a generator. Using a busy loop will halt the game. +from random import uniform + +from game.entities.ai import CircleAi, ChasingAi +from game.scripts.level import Level +from game.constants import GREEN + + +class Level0(Level): + number = 0 + name = "Tutorial" + ground = GREEN + music = "butterfly.ogg" + + def __call__(self): + # self.scene.cloudy() + + yield from super().__call__() + + self.slow_type("You better right code if you want a tutorial", 5) + + # TODO: Check for level clear ? + yield self.pause(15) + # yield from self.slow_type("Well done !", 5, "green", clear=True) diff --git a/game/scripts/level1.py b/game/scripts/level1.py index c8c17bc..37569ea 100644 --- a/game/scripts/level1.py +++ b/game/scripts/level1.py @@ -5,32 +5,78 @@ # Use scene.when to schedule events. # Yield when you want to wait until the next event. # This is a generator. Using a busy loop will halt the game. +from random import uniform +from game.constants import GREEN +from game.scripts.level import Level -def script(app, scene): - when = scene.when - resume = scene.resume - yield - # msg = "Welcome to Butterfly Destroyers!" - # for i in range(len(msg)): - # app.state.terminal.write(msg[i], (i, 0), "red") - # yield scene.sleep(0.1) +class Level1(Level): + number = 1 + name = "The Butterflies Awaken" + ground = GREEN + sky = Level.night_sky + music = "butterfly.ogg" - # print("level") - # yield when.once(0.3, resume) + def __call__(self): + self.scene.cloudy() + self.scene.rocks() + self.scene.stars() + self.scene.rain() - # print("script") - # yield when.once(0.3, resume) + yield from super().__call__() - # print("test") - # yield when.once(0.3, resume) + self.spawn(0, 0) + yield self.small_pause() + self.spawn(0, 0) + yield self.medium_pause() - # print("1") - # yield when.once(0.3, resume) + self.square(0.1) + yield self.medium_pause() - # print("2") - # yield when.once(0.3, resume) + self.square(0.25) + yield self.medium_pause() - # print("3") - # yield when.once(0.3, resume) + for i in range(10): + self.spawn(uniform(-0.3, 0.3), uniform(-0.2, 0.2)) + yield self.small_pause() + + self.medium_pause() + + yield from self.slow_type("The butterflies are organising!", 5, delay=0.08) + yield self.medium_pause() + yield from self.slow_type( + "Destroy them while we still can!", 5, "red", 0.08, True + ) + + yield from self.v_shape(5) + yield self.big_pause() + + self.spawn(0, 0) + yield self.small_pause() + for i in range(1, 4): + self.spawn(i / 14, 0) + self.spawn(-i / 14, 0) + self.spawn(0, i / 14) + self.spawn(0, -i / 14) + yield self.small_pause() + yield self.medium_pause() + + yield from self.circle(20, 0.3, instant=True) + yield self.bigg_pause() + yield from self.circle(20, 0.3, instant=True) + yield self.medium_pause() + + self.spawn_powerup("heart", 0, 0) + yield from self.combine(self.circle(20, 0.2), self.circle(20, -0.2)) + self.huge_pause() + + yield from self.circle(10, 0.1, instant=True) + self.small_pause() + yield from self.circle(5, 0.05, instant=True) + self.medium_pause() + yield from self.circle(15, 0.15, instant=True) + + # TODO: Check for level clear ? + yield self.huge_pause() + yield from self.slow_type("Well done !", 5, "green", clear=True) diff --git a/game/scripts/level2.py b/game/scripts/level2.py new file mode 100644 index 0000000..c8c7b47 --- /dev/null +++ b/game/scripts/level2.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python + +# Level Script + +# Use scene.when to schedule events. +# Yield when you want to wait until the next event. +# This is a generator. Using a busy loop will halt the game. +from math import tau, pi + +from game.constants import GREEN +from game.scripts.level import Level + + +class Level2(Level): + number = 2 + name = "The Rise of Butterflies" + ground = GREEN + music = "butterfly.ogg" + + def __call__(self): + self.scene.cloudy() + self.scene.rocks() + + yield from super().__call__() + + self.spawn(0, 0) + yield from self.rotating_circle(2, 30, 0.5) + yield self.medium_pause() + + self.spawn(0, 0) + yield from self.rotating_circle(3, 30) + yield self.big_pause() + + yield from self.v_shape(5) + yield self.big_pause() + + yield from self.rotating_v_shape(4) + yield self.big_pause() + + for i in range(3): + self.spawn(0, 0) + yield from self.rotating_circle(5, -40) + yield self.big_pause() + + yield from self.slow_type("They plan to swarm us.") + self.spawn_powerup("M", 0, 0) + yield from self.slow_type("Take this Machine Gun!", color="green") + self.terminal.write_center("Press shift to change guns.", 15) + t = "X / Y on controller" + self.terminal.write_center(t, 17) + self.terminal.write_center("X", 17, color="blue", length=len(t)) + self.terminal.write_center( + "Y", 17, color="yellow", length=len(t), char_offset=(4, 0) + ) + + yield self.bigg_pause() + self.terminal.clear(5) + self.terminal.clear(15) + self.terminal.clear(17) + + self.wall(8, 4, 0.2, 0.1) + yield self.bigg_pause() + + self.spawn_powerup("M") + self.medium_pause() + + self.wall(8, 4, 0.2, 0.1) + yield self.big_pause() + + yield from self.combine( + self.rotating_v_shape(5, angular_mult=0.5), + self.rotating_v_shape(5, pi / 3, angular_mult=0.5), + self.rotating_v_shape(5, tau / 3, angular_mult=0.5), + ) + self.spawn_powerup("M") + yield self.bigg_pause() + + yield from self.rotating_circle(5, 10) + yield from self.rotating_circle(7, 20) + yield self.small_pause() + yield from self.rotating_circle(11, 30) + yield self.small_pause() + yield from self.rotating_circle(11, 40) + yield self.big_pause() + + # TODO: Check for level clear ? + yield self.huge_pause() + yield from self.slow_type("Well done!", 5, "green", clear=True) diff --git a/game/scripts/level3.py b/game/scripts/level3.py new file mode 100644 index 0000000..3286363 --- /dev/null +++ b/game/scripts/level3.py @@ -0,0 +1,106 @@ +from math import pi +from random import uniform + +from game.constants import GREEN +from game.scripts.level import Level + + +class Level3(Level): + number = 3 + name = "Attack of the Butterflies" + ground = GREEN + sky = "#040a15" + music = "butterfly.ogg" + + def __call__(self): + self.scene.cloudy() + self.scene.stars() + self.scene.lightning(0.03) + self.scene.rocks(20) + + yield from super().__call__() + + yield from self.slow_type("They try to take us by surprise") + yield from self.slow_type(" But we've got... ") + yield from self.slow_type( + " BIG GUNS! ", color="red", clear=5 + ) + + with self.set_faster(2): + yield from self.v_shape(20) + yield from self.v_shape(10, dir=(0, 1)) + self.spawn_powerup("M") + yield self.medium_pause() + + with self.set_faster(2): + yield from self.combine(self.v_shape(20), self.v_shape(10, dir=(0, 1))) + self.spawn_powerup("M") + + yield self.big_pause() + + # FIXME: Maybe in an other level + yield from self.slow_type("Quick!", self.terminal.size.y / 2, delay=0.05) + + yield from self.rotating_circle(5, 10) + yield from self.rotating_circle(10, 20) + + yield from self.slow_type( + "KILL THEM ALL!", + self.terminal.size.y / 2, + color="red", + delay=0.1, + clear=True, + ) + + with self.set_faster(3): + yield from self.rotating_v_shape(6, angular_mult=0.2) + yield from self.rotating_v_shape(5, pi, angular_mult=0.4) + self.spawn_powerup(letter="heart") + yield from self.rotating_v_shape(3, pi, angular_mult=0.6) + yield self.big_pause() + + for i in range(1, 5): + center = uniform(-0.3, 0.3), uniform(-0.2, 0.2) + self.spawn(*center) + yield from self.rotating_circle( + 11 - 2 * i, 20, speed_mult=1 + i, center=center + ) + yield self.big_pause() + + yield from self.slow_type("They're going damn fast!", delay=0.05) + yield from self.slow_type("We have to hit them faster than light!", delay=0.05) + + self.spawn_powerup("L") + yield self.small_pause() + self.scene.lightning_strike() + yield from self.slow_type("So Laser Gun it is!", 7, "green", 0.01) + yield self.medium_pause() + yield from self.slow_type("...", 12) + yield self.small_pause() + yield from self.slow_type("And engine boost!", 14, "green") + self.engine_boost(1.5) + yield self.big_pause() + self.terminal.clear() + + self.spawn() + for i in range(2): + center = uniform(-0.3, 0.3), uniform(-0.2, 0.2) + self.spawn(*center) + yield from self.rotating_circle(5, 20, speed_mult=3, center=center) + yield self.medium_pause() + self.spawn_powerup("L") + yield self.big_pause() + + yield from self.rotating_v_shape(3, angular_mult=4) + yield self.medium_pause() + self.spawn_powerup("L") + yield self.medium_pause() + + yield from self.combine( + self.rotating_v_shape(3, angular_mult=4), + self.rotating_v_shape(3, angular_mult=4, start_angle=pi), + ) + + # TODO: Check for level clear ? + yield self.huge_pause() + yield from self.slow_type("Well Done!", 5, color="green", clear=True) diff --git a/game/scripts/level4.py b/game/scripts/level4.py new file mode 100644 index 0000000..1abb230 --- /dev/null +++ b/game/scripts/level4.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python + +# Level Script + +# Use scene.when to schedule events. +# Yield when you want to wait until the next event. +# This is a generator. Using a busy loop will halt the game. +from math import pi +from random import uniform + +from game.entities.ai import AvoidAi +from game.scripts.level import Level + + +class Level4(Level): + number = 4 + name = "The Butterfly Menace" + ground = "#F7CA18" + sky = "#303266" + music = "butterfly.ogg" + default_ai = AvoidAi(50, 40) + + def __call__(self): + self.scene.stars() + + # reset in case we are doing the level again + # As it is changed throughout the level + self.default_ai = AvoidAi(50, 40) + + with self.skip(): + yield from super().__call__() + self.engine_boost(1.5) + + text = """The butterflies have been hiding + + in the great Desert. + + + Put it an end + + While they're still weak! + """ + yield from self.slow_type_lines(text, 5, "yellow", 0.05) + + for i in range(10): + self.spawn(uniform(-0.5, 0.5), 0) + yield self.medium_pause() + yield self.medium_pause() + + yield from self.v_shape(4) + yield self.medium_pause() + + yield from self.combine( + self.rotating_v_shape(4, angular_mult=0.5), + self.rotating_v_shape(4, start_angle=pi / 2, angular_mult=0.5), + ) + yield self.bigg_pause() + + yield from self.combine( + self.rotating_v_shape(4, angular_mult=0.4), + self.rotating_v_shape(4, start_angle=pi * 2 / 3, angular_mult=0.4), + self.rotating_v_shape(4, start_angle=pi * 4 / 3, angular_mult=0.4), + ) + yield self.bigg_pause() + + text = """ +They sure are avoiding well. +Take those aiming bullets. +And teach them a lesson!""".splitlines() + yield from self.slow_type(text[1], color="yellow") + yield from self.slow_type(text[2].center(len(text[1])), color="green") + self.spawn_powerup("A", 0, 0) + yield from self.slow_type(text[3].center(len(text[1])), color="yellow") + + yield self.big_pause() + self.terminal.clear() + + self.default_ai.radius *= 1.3 + + with self.set_faster(2): + for i in range(20): + self.spawn(uniform(-0.3, 0.3), 0) + yield self.small_pause() + yield self.big_pause() + + yield from self.combine( + self.rotating_v_shape(5), self.rotating_v_shape(5, start_angle=pi), + ) + yield self.big_pause() + + self.spawn_powerup("A", 0, 0) + yield from self.rotating_circle(5, 30, 1.3) + yield self.big_pause() + + self.spawn(0, 0) + yield from self.rotating_circle(5, 60, 1.5) + self.spawn_powerup("A", 0, 0) + yield self.huge_pause() + + yield from self.rotating_circle( + 8, 100, + ) + self.huge_pause() + + yield from self.rotating_v_shape(4) + self.medium_pause() + yield from self.rotating_v_shape(4) + + # TODO: Check for level clear ? + yield self.huge_pause() + yield from self.slow_type("Well done !", 5, "green", clear=True) diff --git a/game/scripts/level5.py b/game/scripts/level5.py new file mode 100644 index 0000000..fa4874f --- /dev/null +++ b/game/scripts/level5.py @@ -0,0 +1,93 @@ +from math import pi +from random import uniform, choice + +from game.constants import GREEN +from game.entities.ai import RandomFireAi +from game.scripts.level import Level + + +class Level5(Level): + number = 5 + name = "Attack of the Butterflies" + ground = "blue" + sky = "#ff6500" + music = "butterfly.ogg" + default_ai = RandomFireAi(2, 10) + + def __call__(self): + self.scene.cloudy() + self.scene.stars() + self.scene.lightning(0.03) + self.scene.rocks(20) + + with self.skip(): + yield from super().__call__() + + yield from self.slow_type("Since their last beating") + yield self.small_pause() + yield from self.slow_type("They put their hands on guns", 7) + yield self.small_pause() + yield from self.slow_type("And are now", 9) + yield self.small_pause() + yield from self.slow_type("ATTACKING US!", 12, "red", 0.03) + + for i in range(5): + self.terminal.write_center("ATTACKING US!", 12, "red") + yield self.pause(0.1) + self.terminal.clear(12) + yield self.pause(0.1) + self.terminal.clear() + + self.spawn() + yield self.small_pause() + self.spawn() + yield self.small_pause() + self.spawn() + yield self.small_pause() + + yield from self.slow_type("Keep moving!") + yield self.small_pause() + yield from self.slow_type("Engine boost :)", 13, "green") + self.engine_boost(1.5) + + self.square(0.1) + yield self.medium_pause() + self.terminal.clear() + + for i in range(51): + self.spawn(uniform(-0.3, 0.2), uniform(-0.2, 0.2)) + self.spawn(uniform(-0.3, 0.2), uniform(-0.2, 0.2)) + yield self.small_pause() + + if i % 20 == 10: + self.spawn_powerup("heart", uniform(-0.3, 0.2), uniform(-0.2, 0.2)) + if i % 20 == 19: + self.spawn_powerup( + choice("AM"), uniform(-0.3, 0.2), uniform(-0.2, 0.2) + ) + + yield from self.rotating_circle(10, 20) + self.big_pause() + yield from self.rotating_v_shape(5) + self.spawn_powerup("M", 0.2) + self.spawn_powerup("A", -0.2) + self.spawn_powerup("L", -0) + yield self.bigg_pause() + + self.wall(4, 4, 0.3, 0.3) + yield self.medium_pause() + + self.spawn_powerup("heart", -0.2) + self.spawn_powerup("A", 0.2) + yield self.big_pause() + + self.wall(4, 4, 0.3, 0.3) + yield self.bigg_pause() + + self.spawn() + for i in range(3): + yield from self.circle(5, 10 * i) + + # TODO: Check for level clear ? + yield self.huge_pause() + yield from self.slow_type("Well Done!", 5, color="green", clear=True) diff --git a/game/scripts/level6.py b/game/scripts/level6.py new file mode 100644 index 0000000..bc3fa27 --- /dev/null +++ b/game/scripts/level6.py @@ -0,0 +1,47 @@ +from game.entities.ai import ChasingAi, AvoidAi, RandomFireAi +from game.entities.buttabomber import ButtaBomber +from game.entities.flyer import Flyer +from game.scripts.level import Level + + +class Level6(Level): + number = 6 + name = "The Return of the Butterfly" + ground = "#F0E149" + sky = "#562A85" + music = "butterfly.ogg" + default_ai = ChasingAi + + def __call__(self): + self.scene.cloudy() + self.scene.rocks(30) + self.scene.stars() + self.scene.rain() + # self.scene.lightning_strike() + + yield from super().__call__() + + with self.skip(): + self.engine_boost(1.5 ** 2) + + yield from self.slow_type("This level is still in construction") + yield from self.slow_type("Sorry :(", 7, "red", 0.3) + + yield from self.slow_type("Here is some missing content", 9) + + yield self.medium_pause() + self.terminal.clear() + + # self.spawn_powerup("star", 0, 0) + # yield self.pause(10) + + for x in range(3): + for i in range(1, 5): + self.square(i * 0.1, None, ButtaBomber) + yield self.small_pause() + self.square(0.25, None, Flyer) + yield self.bigg_pause() + + # TODO: Check for level clear ? + yield self.huge_pause() + yield from self.slow_type("Well done !", 5, "green", clear=True) diff --git a/game/scripts/level7.py b/game/scripts/level7.py new file mode 100644 index 0000000..fe3c6ad --- /dev/null +++ b/game/scripts/level7.py @@ -0,0 +1,43 @@ +from game.constants import GREEN +from game.scripts.level import Level + +from game.entities.butterfly import Butterfly +from game.entities.buttabomber import ButtaBomber +from game.entities.flyer import Flyer +from game.entities.boss import Boss + + +class Level7(Level): + number = 7 + name = "The Last Butterfly" + ground = "darkblue" + sky = "darkred" + music = "butterfly2.ogg" + + def __call__(self): + # self.scene.cloudy() + # self.scene.rocks() + # self.scene.stars() + # self.scene.rain() + self.scene.lightning() + + yield from super().__call__() + self.engine_boost(1.5) + + boss = self.spawn(0, 0, None, Boss) + + while True: + if not boss.alive: + break + self.terminal.write(" ", 20) + self.terminal.write("|" * (boss.hp // 50), 20, "red") + # boss.hp -= 1 + if not boss.alive or boss.hp <= 0: + boss.explode() + boss.remove() + break + yield self.script.sleep(1) + + yield self.script.sleep(1) + + self.app.state = "credits" diff --git a/game/states/credits.py b/game/states/credits.py new file mode 100644 index 0000000..754bc48 --- /dev/null +++ b/game/states/credits.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python +import math +import pygame +import glm + +from game.base.state import State +from game.entities.camera import Camera +from game.entities.terminal import Terminal +from game.entities.ground import Ground +from game.constants import GROUND_HEIGHT, CAMERA_OFFSET, SCRIPTS_DIR +from game.base.stats import Stats +from game.scene import Scene +from game.util import clamp, ncolor, pg_color + + +class Credits(State): + def __init__(self, app, state=None): + + super().__init__(app, state, self) + + self.scene = Scene(self.app, self) + + self.terminal = self.scene.add(Terminal(self.app, self.scene)) + self.camera = self.scene.add(Camera(app, self.scene, self.app.size)) + + self.time = 0 + self.bg_color = ncolor("darkred") + + def pend(self): + self.app.pend() + + def update(self, dt): + super().update(dt) # needed for script + + self.scene.update(dt) + self.time += dt + self.bg_color = ( + ncolor("darkgreen") + math.sin(self.time % 1 * math.tau * 2) * 0.05 + ) + + def render(self): + + self.app.screen.fill(pg_color(self.bg_color)) + self.scene.render(self.camera) + + def __call__(self, script): + yield + scene = self.scene + terminal = self.terminal + self.scene.music = "butterfly.ogg" + when = script.when + + # self.scene.sky_color = "#4c0b6b" + # self.scene.ground_color = "#e08041" + self.scene.stars() + self.scene.cloudy() + + textdelay = 0.02 + + fades = [ + when.fade( + 10, + (0, 1), + lambda t: scene.set_sky_color( + glm.mix(ncolor("#4c0b6b"), ncolor("#e08041"), t) + ), + ), + when.fade( + 10, + (0, 1), + lambda t: scene.set_ground_color( + glm.mix(ncolor("darkgreen"), ncolor("yellow"), t) + ), + lambda: fades.append( + when.every(0, lambda: scene.set_ground_color(scene.ground_color)) + ), + ), + ] + yield + + pages = [ + [ + "CREDITS", + "", + "flipcoder", + " " + "Programming, Music, Sounds", + "ddorn", + " " + "Programming, Graphics", + "MysteryCoder456", + " " + "Programming", + "Tamwile", + " " + "Graphics", + "Jtiai", + " " + "Sounds", + "", + "Additional Assets: ", + " opengameart.org/users/pitrizzo", + ], + [ + "This game was created by PythonixCoders", + "for PyWeek 29, a week-long Python game", + "jam, where individuals or groups give", + "themselves only one week to create a", + "game.", + "", + "Participate next time at pyweek.org", + ], + ] + for p, page in enumerate(pages): + for y, line in enumerate(page): + if line: + scene.ensure_sound("message.wav") + if p == 0: + if line == "CREDITS": + col = "white" + elif not line.startswith(" "): + col = "green" + else: + col = "white" + else: + col = "white" + for x, m in enumerate(line): + terminal.write(m, (x + 1, y + 1), col) + # terminal.write(m[:x], (x + 1, y * 2 + 3), "white") + # terminal.write(m[-1], (x + 1 + len(m) - 1, y * 2 + 3), "red") + yield script.sleep(0.01) + self.scene.play_sound("message.wav") + else: + continue + delay = 0.1 + yield script.sleep(4) + self.terminal.clear() + + terminal.write_center("Thanks for Playing!!!", 10) + while True: + if script.keys_down: + break + yield script.sleep(0.1) + + self.app.state = None diff --git a/game/states/game.py b/game/states/game.py index 1dd76d2..a80019a 100755 --- a/game/states/game.py +++ b/game/states/game.py @@ -1,17 +1,26 @@ #!/usr/bin/env python -import random -from random import randrange +import os +import re +from functools import lru_cache -from glm import vec2, vec3 +import pygame +from glm import vec3, sign +from random import randint -from game.entities.ground import Ground from game.scene import Scene +from game.base.inputs import Inputs, Axis, Button, JoyAxis, JoyButton, JoyAxisTrigger from game.base.state import State -from game.entities.butterfly import Butterfly, random_color +from game.constants import CAMERA_OFFSET, SCRIPTS_DIR, DEBUG from game.entities.camera import Camera -from game.entities.ship import Ship +from game.entities.player import Player from game.entities.terminal import Terminal -from game.level import BaseLevelBuilder +from game.entities.powerup import Powerup +from game.base.enemy import Enemy +from game.entities.buttabomber import ButtaBomber +from game.entities.flyer import Flyer +from game.util import pg_color, ncolor +from game.base.signal import SlotList +from game.base.stats import Stats class Game(State): @@ -19,28 +28,122 @@ def __init__(self, app, state=None): super().__init__(app, state) - self.scene = Scene(self.app) + self.scene = Scene(self.app, self) + self.gui = Scene(self.app, self) + self.slots = SlotList() + self.paused = False + # self.scene.add(ButtaBomber(app, self.scene, vec3(0, 0, -3000))) + # self.scene.add(Powerup(app, self.scene, 'star', position=vec3(0, 0, -3000))) + + # create terminal first since player init() writes to it + self.terminal = self.gui.add(Terminal(self.app, self.scene)) + + self.app.inputs = self.build_inputs() self.camera = self.scene.add(Camera(app, self.scene, self.app.size)) - self.scene.add(Ground(app, self.scene, -300)) - self.player = self.scene.add(Ship(app, self.scene)) - self.terminal = self.scene.add(Terminal(self.app, self.scene)) - # control the camera - self.app.add_event_listener(self.player) + stats = self.stats = self.app.data["stats"] = self.app.data.get( + "stats", Stats() + ) + self.level = stats.level + self.player = self.scene.add(Player(app, self.scene, level=self.level)) - self.level = BaseLevelBuilder().uniform(10, 8) + # self.scripts += self.score_screen # self.camera.slots.append( # self.player.on_move.connect(lambda: self.camera.update_pos(self.player)) # ) - # when camera moves, set our dirty flag to redraw - # self.camera.on_pend.connect(self.pend) - - # self.camera.position = app.size/2 + self.debug = False + self.slots += [ + app.inputs["debug"].on_press(lambda _: self.debug_mode(True)), + app.inputs["debug"].on_release(lambda _: self.debug_mode(False)), + ] + self.slots += [ + app.inputs["pause"].on_press(self.toggle_pause), + ] self.time = 0 + # score backdrop + backdrop_h = int(24 * 1.8) + + # draw a score backdrop + rows = 8 + for i in range(rows): + h = int(backdrop_h) // rows + y = h * i + backdrop = pygame.Surface((self.app.size.x, h)) + interp = i / rows + interp_inv = 1 - i / rows + backdrop.set_alpha(255 * interp * 0.4) + # backdrop.fill((0)) + backdrop.fill(pg_color(ncolor("white") * interp_inv)) + self.scene.on_render += lambda _, y=y, backdrop=backdrop: self.app.screen.blit( + backdrop, (0, y) + ) + + # backdrop = pygame.Surface((self.app.size.x, h)) + # backdrop.set_alpha(255 * interp) + # backdrop.fill((0)) + + # backdrop_h = int(24) + # rows = 4 + # for i in range(rows, 0, -1): + # h = (int(backdrop_h) // rows) + # y = h * i + # backdrop = pygame.Surface((self.app.size.x, h)) + # interp = i/rows + # interp_inv = 1 - i/rows + # backdrop.set_alpha(200 * interp_inv) + # backdrop.fill((0)) + # # backdrop.fill(pg_color(ncolor('black')*interp_inv)) + # self.scene.on_render += lambda _, y=y,backdrop=backdrop: self.app.screen.blit(backdrop, (0,self.app.size.y-y)) + + # self.scene.on_render += lambda _: self.app.screen.blit(self.backdrop, (0,int(self.app.size.y-backdrop_h))) + + # self.scripts += self.score_screen + + def toggle_pause(self, *args): + if not self.player or not self.player.alive: + self.app.state = "game" + return + + self.paused = not self.paused + if self.paused: + self.terminal.write_center( + "- GAME PAUSED -", 10, + ) + # self.scene.play_sound('pause.wav') + else: + self.terminal.clear(10) + # self.scene.play_sound('pause.wav') + + @staticmethod + @lru_cache(maxsize=1) + def level_count(): + level_regex = re.compile("level(\\d+).py") + count = 0 + for path in os.listdir(SCRIPTS_DIR): + if re.match(level_regex, path): + count += 1 + return count + 1 + + @property + def level(self): + return self._level + + @level.setter + def level(self, value): + self._level = value % self.level_count() + self.scene.script = f"level{self.level}" + + def debug_mode(self, b): + self.debug = b + for i in range(9): + self.terminal.clear(13 + i) + if not b: + self.player.write_weapon_stats() + def pend(self): # self.dirty = True @@ -51,68 +154,98 @@ def update(self, dt): Called every frame by App as long as Game is the current app.state :param dt: time since last frame in seconds """ + if self.paused: + return + + super().update(dt) # needed for state script (unused) - if self.level.is_over(): - if random.random() < 0.5: - self.level = BaseLevelBuilder().uniform(10, 5) - else: - self.level = BaseLevelBuilder().circle(30, 4) + if self.scene.script and self.scene.script.done(): + self.app.state = "intermission" + return - self.spawn(self.level.update(dt)) self.scene.update(dt) - # self.update_camera() - self.camera.position = self.player.position + self.gui.update(dt) + + # Update the camera according to the player position + # And movement + self.camera.position = self.player.position + CAMERA_OFFSET + self.camera.up = vec3(0, 1, 0) + d = self.player.velocity.x / self.player.speed.x + if d: + self.camera.rotate_around_direction(-d * 0.05) self.time += dt + assert self.scene.blocked == 0 + def render(self): """ Clears screen and draws our scene to the screen Called every frame by App as long as Game is the current app.state """ - # Render Player's Score - score_display = "Score: {}".format(self.camera.position) - score_pos = (self.terminal.size.x - len(score_display), 0) - self.terminal.write(score_display, score_pos) + # Render Player's Position + # pos_display = "Position: {}".format(self.player.position) + # pos_pos = (self.terminal.size.x - len(pos_display), 0) + # self.terminal.write(pos_display, pos_pos) - self.scene.render(self.camera) + # self.debug = True + if self.debug: + self.terminal.write( + "Sc/when: " + str(len(self.scene.script.when)) + " ", 14 + ) + self.terminal.write("S/when: " + str(len(self.scene.when)) + " ", 15) + self.terminal.write("SL: " + str(len(self.scene.slotlist)) + " ", 16) + self.terminal.write("Res: " + str(len(self.app.cache)) + " ", 17) + self.terminal.write(f"FPS low: {self.scene.lowest_fps} ", 18) + self.terminal.write(f"Pmax: {self.scene.max_particles} ", 19) + self.terminal.write(f"Entities: {len(self.scene.slots)} ", 20) + self.terminal.write(f"FPS: {self.app.fps} ", 21) - def spawn(self, positions): + self.scene.render(self.camera) + self.gui.render(self.camera) + + assert self.scene.blocked == 0 + + def build_inputs(self): + pg = pygame + + pg.joystick.quit() # Reload + pg.joystick.init() + for j in range(pg.joystick.get_count()): + j = pg.joystick.Joystick(j) + j.init() + + inputs = Inputs() + inputs["hmove"] = Axis( + (pg.K_LEFT, pg.K_a), (pg.K_RIGHT, pg.K_d), JoyAxis(0, 0), smooth=0.1 + ) + inputs["vmove"] = Axis( + (pg.K_DOWN, pg.K_s), (pg.K_UP, pg.K_w), JoyAxis(0, 1, True), + ) + inputs["fire"] = Button( + pg.K_SPACE, + pg.K_RETURN, + JoyButton(0, 1), + JoyButton(0, 0), + JoyAxisTrigger(0, 2, 0), + JoyAxisTrigger(0, 5, 0), + ) + inputs["debug"] = Button(pg.K_TAB) + inputs["switch-gun"] = Button( + pg.K_RSHIFT, pg.K_LSHIFT, JoyButton(0, 3), JoyButton(0, 2) + ) + # inputs["test"] = Button(pg.K_p) + inputs["pause"] = Button(pg.K_ESCAPE, JoyButton(0, 6), JoyButton(0, 7)) + return inputs + + def restart(self): """ - Spawn butterflies on the right of the screen. - :param ys: list of positions between -1 and 1 + Called by player when() event after death """ + # clear terminal + self.scene.clear_type(Enemy) + self.scene.clear_type(Powerup) - for pos in positions: - pos = vec2(pos) * self.app.size / 2 - pos = self.camera.rel_to_world(vec3(*pos, -self.camera.screen_dist)) - - butt = Butterfly( - self.app, self.scene, pos, random_color(), randrange(2, 6), 0 - ) - - self.scene.add(butt) - - def update_camera(self): - - edge = vec3( - 250, 100, 0 - ) # Maximum distance at which the ship can be from the edge of the screen until the camera moves - cam_speed = vec3(0, 0, self.player.velocity.z) - spd = self.player.velocity - - if self.player.position.x < edge.x: - cam_speed += vec3(spd.x, 0, 0) - self.player.position.x = edge.x - elif self.player.position.x > self.app.size.x - edge.x: - cam_speed += vec3(spd.x, 0, 0) - self.player.position.x = self.app.size.x - edge.x - - if self.player.position.y < edge.y: - cam_speed += vec3(0, spd.y, 0) - self.player.position.y = edge.y - elif self.player.position.y > self.app.size.y - edge.y: - cam_speed += vec3(0, spd.y, 0) - self.player.position.y = self.app.size.y - edge.y - - self.camera.velocity = cam_speed + for x in range(2, 20): + self.terminal.clear() + self.level = self._level # retriggers diff --git a/game/states/intermission.py b/game/states/intermission.py new file mode 100644 index 0000000..e7fe84a --- /dev/null +++ b/game/states/intermission.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python +import math +from math import log + +import pygame +import glm + +from game.base.state import State +from game.entities.camera import Camera +from game.entities.terminal import Terminal +from game.entities.ground import Ground +from game.constants import GROUND_HEIGHT, CAMERA_OFFSET, SCRIPTS_DIR +from game.base.stats import Stats +from game.constants import EPSILON +from game.scene import Scene +from game.util import clamp, ncolor + + +class Intermission(State): + def __init__(self, app, state=None): + + super().__init__(app, state, self) + + self.stats = self.app.data["stats"] = self.app.data.get("stats", Stats()) + + self.scene = Scene(self.app, self) + + self.terminal = self.scene.add(Terminal(self.app, self.scene)) + self.camera = self.scene.add(Camera(app, self.scene, self.app.size)) + self.ground = self.scene.add(Ground(app, self.scene, GROUND_HEIGHT)) + self.terminal.position.z -= 10 + + self.time = 0 + # self.bg_color = ncolor("darkred") + + def pend(self): + self.app.pend() + + def update(self, dt): + super().update(dt) # needed for script + + self.scene.update(dt) + self.time += dt + # self.bg_color = ( + # ncolor("darkgreen") + math.sin(self.time % 1 * math.tau * 2) * 0.05 + # ) + + def render(self): + + # self.app.screen.fill( + # pygame.Color(*[int(clamp(x * 255, 0, 255)) for x in self.bg_color]) + # ) + self.scene.render(self.camera) + + def __call__(self, script): + yield + scene = self.scene + terminal = self.terminal + stats = self.stats + self.scene.music = "intermission.ogg" + when = script.when + + # self.scene.sky_color = "#4c0b6b" + # self.scene.ground_color = "#e08041" + self.scene.stars() + self.scene.cloudy() + + textdelay = 0.02 + + fade = [] + fades = [ + when.fade( + 10, + (0, 1), + lambda t: scene.set_sky_color( + glm.mix(ncolor("#4c0b6b"), ncolor("#e08041"), t) + ), + ), + when.fade( + 10, + (0, 1), + lambda t: scene.set_ground_color( + glm.mix(ncolor("darkgreen"), ncolor("yellow"), t) + ), + lambda: fades.append( + when.every(0, lambda: scene.set_ground_color(scene.ground_color)) + ), + ), + ] + yield + + msg = [ + ("Level " + str(stats.level), "COMPLETED"), + ("Damage Done", int(stats.damage_done)), + ("Damage Taken", int(stats.damage_taken)), + ("Kills", int(stats.kills)), + # ("Lives Remaining", stats.lives), + None, + ("Score", stats.score), + ] + for y, line in enumerate(msg): + if line: + scene.ensure_sound("message.wav") + for x, m in enumerate(line[0]): + terminal.write(m, (x + 1, y * 2 + 3), "white") + # terminal.write(m[:x], (x + 1, y * 2 + 3), "white") + # terminal.write(m[-1], (x + 1 + len(m) - 1, y * 2 + 3), "red") + yield script.sleep(0.01) + else: + continue + if isinstance(line[1], int): # total + dd = 0 + for val in range(0, line[1] + 1): + terminal.write( + str(val), + (self.terminal.size.x - len(str(val)) - 1, y * 2 + 3), + "white", + ) + dd += 0.6 / (val + 2) + if dd > 1 / max(self.app.fps, 10): + yield script.sleep(dd) + dd = 0 + else: + yield script.sleep(0.1) + terminal.write( + str(line[1]), + (self.terminal.size.x - len(str(line[1])) - 1, y * 2 + 3), + "green", + ) + self.scene.play_sound("hit.wav") + yield script.sleep(0.01) + + yield script.sleep(2) + # while True: + + # terminal.write_center("Press any key to continue", 20, "green") + # yield script.sleep(0.2) + # if script.keys_down: + # break + # terminal.clear(20) + # yield script.sleep(0.2) + # if script.keys_down: + # break + + self.stats.level += 1 + self.app.state = "game" diff --git a/game/states/intro.py b/game/states/intro.py index 4ed636a..48aaad3 100644 --- a/game/states/intro.py +++ b/game/states/intro.py @@ -1,29 +1,74 @@ #!/usr/bin/env python -import random -from random import randrange - -from glm import vec2, vec3 -from game.scene import Scene from game.base.state import State -from game.entities.butterfly import Butterfly, random_color from game.entities.camera import Camera -from game.entities.player import Player from game.entities.terminal import Terminal -from game.level import BaseLevelBuilder +from game.entities.ground import Ground +from game.constants import GROUND_HEIGHT, CAMERA_OFFSET, SCRIPTS_DIR +from game.scene import Scene +from game.util import pg_color, random_rgb, random_char, ncolor +import pygame +import glm +import random +import math +from glm import vec3, vec4, ivec2 class Intro(State): def __init__(self, app, state=None): - super().__init__(app, state) + super().__init__(app, state, self) - self.scene = Scene(self.app, "intro") + self.scene = Scene(self.app, self) self.terminal = self.scene.add(Terminal(self.app, self.scene)) + self.bigterm = self.scene.add(Terminal(self.app, self.scene, 32)) self.camera = self.scene.add(Camera(app, self.scene, self.app.size)) - + self.scene.ground_color = "darkgreen" self.time = 0 + rows = 8 + backdrop_h = 150 + for i in range(rows): + h = int(backdrop_h) // rows + y = h * i + backdrop = pygame.Surface((self.app.size.x, h)) + interp = i / rows + interp_inv = 1 - i / rows + backdrop.set_alpha(255 * interp_inv * 0.2) + backdrop.fill(pg_color(ncolor("white") * interp_inv)) + self.scene.on_render += lambda _, y=y, backdrop=backdrop: self.app.screen.blit( + backdrop, (0, y) + ) + + rows = 8 + backdrop_h = 100 + for i in range(rows): + h = int(backdrop_h) // rows + y = h * i + backdrop = pygame.Surface((self.app.size.x, h)) + interp = i / rows + interp_inv = 1 - i / rows + backdrop.set_alpha(255 * interp_inv * 0.1) + backdrop.fill(pg_color(ncolor("white") * interp_inv)) + self.scene.on_render += lambda _, y=y, backdrop=backdrop: self.app.screen.blit( + backdrop, (0, y) + ) + + backdrop_h = int(24) + rows = 4 + for i in range(rows, 0, -1): + h = int(backdrop_h) // rows + y = h * i + backdrop = pygame.Surface((self.app.size.x, h)) + interp = i / rows + interp_inv = 1 - i / rows + backdrop.set_alpha(200 * interp_inv) + backdrop.fill((0)) + # backdrop.fill(pg_color(ncolor('black')*interp_inv)) + self.scene.on_render += lambda _, y=y, backdrop=backdrop: self.app.screen.blit( + backdrop, (0, self.app.size.y - y) + ) + def pend(self): self.app.pend() # tell app we need to update @@ -34,9 +79,157 @@ def update(self, dt): :param dt: time since last frame in seconds """ + super().update(dt) # needed for script + self.scene.update(dt) self.time += dt def render(self): self.scene.render(self.camera) + + def change_logo_color(self, script): + yield + bigterm = self.bigterm + + while True: + if self.scene.ground_color: + break + yield + c = glm.mix( + self.scene.ground_color, + glm.mix(ncolor("white"), random_rgb(), random.random()), + 0.2, + ) + + r = 0 + # rc = vec4() + self.scene.play_sound("explosion.wav") + while True: + if r % 30 == 0: + rc = random_rgb() + s = "BUTTERFLY " + for i in range(len(s)): + # c = ncolor('purple') * i/len(s) + math.sin(r / 200 + i+r) ** 2 + .6 + c = ( + ncolor("purple") * i / len(s) + + ((math.sin(i + r) + 0.4) * script.dt) + + 0.3 + ) + bigterm.write(s[i], (i - len(s) - 8, 1), c) + if r > 15: + s = "DESTROYERS " + for i in range(len(s)): + c = ( + self.scene.ground_color * i / len(s) + + ((math.sin(i + r) + 4) * script.dt) + + 0.3 + ) + bigterm.write(s[i], (i - len(s) - 3, 2), c) + if r == 15: + self.scene.play_sound("explosion.wav") + yield script.sleep(0.1) + r += 1 + + def __call__(self, script): + yield + + self.scene.scripts += self.change_logo_color + + when = script.when + scene = self.scene + terminal = self.terminal + + self.scene.music = "butterfly2.ogg" + # self.scene.sky_color = "#4c0b6b" + # self.scene.ground_color = "#e08041" + # self.scene.stars() + self.scene.cloudy() + + textdelay = 0.03 + + fades = [ + when.fade( + 10, + (0, 1), + lambda t: scene.set_sky_color_opt( + glm.mix(ncolor("#4c0b6b"), ncolor("#e08041"), t) + ), + ), + when.fade( + 10, + (0, 1), + lambda t: scene.set_ground_color_opt( + glm.mix(ncolor("darkgreen"), ncolor("yellow"), t) + ), + lambda: fades.append( + when.every( + 0, lambda: scene.set_ground_color_opt(scene.ground_color) + ) + ), + ), + ] + yield + + # self.scene.set_ground_color = "#e08041" + + # scene.sky_color = "black" + self.scene.music = "butterfly2.ogg" + + # for i in range(len(msg)): + # terminal.write(msg[i], (len(msg) / 2 - 1 + i, 1), self.scene.ground_color) + # # scene.ensure_sound("type.wav") + # yield script.sleep(0.002) + + # script.push(self.logo_color) + + # yield from self.change_logo_color(script) + + yield script.sleep(3) + + msg = [ + "In the year 20XX, the butterfly", + "overpopulation problem has", + "obviously reached critical mass.", + "The military has decided to intervene.", + "Your mission is simple: defeat all the", + "butterflies before the world ends.", + "But look out for Big Butta, king of", + "the butterflies.", + ] + for y, line in enumerate(msg): + ty = y * 2 + 5 + for x, m in enumerate(line): + terminal.write(random_char(), (x + 2, ty), random_rgb()) + cursor = (x + 2, ty) + terminal.write(m, (x + 1, ty), "white") + # scene.ensure_sound("type.wav") + self.change_logo_color(script) + # if not script.keys_down: + # yield + # else: + yield script.sleep(textdelay) + terminal.clear(cursor) + + when = script.when + scene = self.scene + terminal = self.terminal + + yield script.sleep(3) + + # while True: + # terminal.write_center("Press any key to continue", 20, "green") + # self.change_logo_color(script) + # yield script.sleep(0.1) + # if script.keys_down: + # break + # terminal.clear(20) + # self.change_logo_color(script) + # yield script.sleep(0.1) + # if script.keys_down: + # break + + terminal.clear() + terminal.write_center("Loading...", 10) + + self.app.state = "game" diff --git a/game/states/menu.py b/game/states/menu.py new file mode 100644 index 0000000..450e029 --- /dev/null +++ b/game/states/menu.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python + +from game.base.state import State +from game.entities.camera import Camera +from game.entities.terminal import Terminal +from game.scene import Scene + + +class Menu(State): + def __init__(self, app, state=None): + + super().__init__(app, state, self) + + self.scene = Scene(self.app, self) + self.terminal = self.scene.add(Terminal(self.app, self.scene)) + self.camera = self.scene.add(Camera(app, self.scene, self.app.size)) + + self.time = 0 + + def pend(self): + self.app.pend() + + def update(self, dt): + super().update(dt) # needed for script + + self.scene.update(dt) + self.time += dt + + def render(self): + + self.scene.render(self.camera) + + def __call__(self, script): + while True: + yield diff --git a/game/util.py b/game/util.py new file mode 100755 index 0000000..9ac9817 --- /dev/null +++ b/game/util.py @@ -0,0 +1,286 @@ +from colorsys import rgb_to_hsv, hsv_to_rgb +import random +from functools import lru_cache +from typing import Union, Optional + +import glm # for mix (conflicts with util.mix) +from glm import vec3, vec4, ivec2, ivec3, ivec4, normalize, cross, dot, vec2 + +from game.constants import EPSILON, DEBUG +import pygame + + +def map_range(val, r1, r2): + return (val - r1[0]) / (r1[1] - r1[0]) * (r2[1] - r2[0]) + r2[0] + + +def clamp(x, mini=0, maxi=1): + if mini > maxi: + return x + if x < mini: + return mini + if x > maxi: + return maxi + return x + + +def surf_fader(max_dist, dz): + """ + Get alpha value for fade. + + Arguments: + max_dist {int} -- Maximum distance until butterflies disappear completely + dz {int} -- Difference of Z pos between camera and butterfly + """ + + return clamp((max_dist - dz) / max_dist * 255, 0, 255) + + +def rgb2hsv(r, g, b): + """Conversion between rgb in range 0-255 to hsv""" + return rgb_to_hsv(r / 255, g / 255, b / 255) + + +def hsv2rgb(h, s, v): + """Conversion between hsv to rgb in range 0-255""" + s = clamp(s) + v = clamp(v) + + r, g, b = hsv_to_rgb(h % 1, s, v) + return ( + int(r * 255), + int(g * 255), + int(b * 255), + ) + + +def random_color(): + """Random RGB color of the rainbow""" + return hsv2rgb(random.random(), 1, 1) + + +def plane_intersection(p1: vec3, d1: vec3, p2: vec3, d2: vec3): + """ + Compute the line of intersection of the two planes. + + Note: if the two planes are parallel or equal this returns None. + + :param p1: a point in the first plane + :param d1: a vector normal to the first plane + :param p2: a point in the second plane + :param d2: a normal vector of the second plane + :return: None if they are parallel else (p3, d3) + where p3 is a point in the line of intersection + and d3 is the direction of this line + """ + + d1 = normalize(d1) + d2 = normalize(d2) + + if d1 in (d2, -d2): + # planes are parallel + return None + + d3 = cross(d1, d2) + + # d3 and v1 are an orthonormal base of the first plane + v1 = cross(d3, d1) + b = -dot(p1, d2) / dot(v1, d2) + + p3 = p1 + b * v1 + return p3, d3 + + +def cross2d(a, b): + return a.x * b.y - a.y * b.x + + +def line_intersection(p, u, q, v) -> Optional[vec2]: + """ + Compute the intersection of two 2d lines. + + :param p: a point on the first line + :param u: direction of the first line + :param q: a point on the first line + :param v: direction of the first line + :return: None if no intersection + """ + + cross = cross2d(u, v) + if abs(cross) < EPSILON: + return None + + w = p - q + s = cross2d(u, w) / cross + return p + s * u + + +def line_segment_intersection(a, b, p, u) -> Optional[vec2]: + """ + Compute the intersection between a 2d line and a segment. + + :param a: start point of the segment + :param b: end point of the segment + :param p: a point of the line + :param u: the direction of the line + :return: + """ + + v = b - a + cross = cross2d(v, u) + if abs(cross) < EPSILON: + return None + + w = a - p + s = cross2d(v, w) / cross + if 0 <= s <= 1: + return a + s * v + return None + + +def estimate_3d_size(size_2d): + """ + Return a 3D size given a sprite size. + Last coordinate is set to the minimum dimension of the two first. + """ + + return vec3(*size_2d, min(size_2d)) + + +def pg_color(c): + tc = type(c) + if tc == pygame.Color: + return c + elif tc == vec3: + c = vec4(c, 0) + elif tc == ivec3: + c = vec4(c, 0) + elif tc == ivec4: + c = vec4(c, 0) + elif tc == tuple: + return c + elif tc == str: + return pygame.Color(c) + c = tuple(int(clamp(x * 255, 0, 255)) for x in c) + return c + + +def ncolor(c): + """ + Normalize color based on type. + Given a color string, a pygame color, or vec3, + return that as a normalized vec4 color + """ + tc = type(c) + if tc == str: + c = vec4(*pygame.Color(c)) / 255.0 + elif tc == tuple: + c = vec4(*c, 0) / 255.0 + elif tc == c or tc == pygame.Color: + c = vec4(*c) / 255.0 + elif tc == vec3: + c = vec4(*c, 0) + elif tc == float or tc == int: + c = vec4(c, c, c, 0) + elif c is None: + c = vec4(0) + return c + + +def rgb_mix(a, b, t): + if t >= 1: + return b + if t <= 0: + return a + + return ( + int(a[0] * (1 - t) + b[0] * t), + int(a[1] * (1 - t) + b[1] * t), + int(a[2] * (1 - t) + b[2] * t), + ) + + +def nrand(s=1.0): + """ + normalized random scalar, scaled by S + """ + return (random.random() * 2 - 1) * s + + +def mix(a, b, t): + """ + interpolate a -> b @ t + Returns a vec4 + Supports color names and pygame colors + """ + if isinstance(a, vec3): + return glm.mix(a, b, t) + + # this works for vec4 as well + return glm.mix(ncolor(a), ncolor(b), t) + + +def random_vec3(s=1): + return glm.normalize(vec3(nrand(), nrand(), nrand())) * s + + +def random_rgb(): + return vec4(random.random(), random.random(), random.random(), 0) + + +def random_char(): + """ + Random human-readable char + """ + return chr(random.randint(32, 126)) + + +def rand_RGB(): + return ( + random.randrange(255), + random.randrange(255), + random.randrange(255), + ) + + +@lru_cache(15) +def noise_surf(size, num=0): + surf = pygame.Surface(size) + for y in range(size[1]): + for x in range(size[0]): + surf.set_at((x, y), rand_RGB()) + surf.set_alpha(12) + return surf + + +@lru_cache(15) +def noise_surf_dense_bottom(size, num=0): + surf = pygame.Surface(size).convert_alpha() + for y in range(size[1]): + interp = 1 - y / size[1] + alpha = min(int(0.02 / interp * 255), 255) + for x in range(size[0]): + surf.set_at( + (x, y), + ( + min(random.randrange(10, 255) / interp / 6, 255), + min(random.randrange(10, 255) / interp / 6, 255), + min(random.randrange(10, 255) / interp / 6, 255), + alpha, + ), + ) + return surf + + +def debug_log_call(f: "function"): + if DEBUG: + + def wrapper(*args, **kwargs): + ar = [str(a) for a in args] + kw = ["{n}={v}" for n, v in kwargs.items()] + print(f"CALL {f.__name__}({', '.join(ar + kw)})") + return f(*args, **kwargs) + + return wrapper + + return f diff --git a/game/util/util.py b/game/util/util.py deleted file mode 100755 index 22a4870..0000000 --- a/game/util/util.py +++ /dev/null @@ -1,131 +0,0 @@ -from colorsys import rgb_to_hsv, hsv_to_rgb -from random import random -from typing import Union, Optional - -from glm import vec3, normalize, cross, dot, vec2 - -from game.constants import EPSILON - - -def clamp(x, mini=0, maxi=1): - if mini > maxi: - return x - if x < mini: - return mini - if x > maxi: - return maxi - return x - - -def surf_fader(max_dist, dz): - """ - Get alpha value for fade. - - Arguments: - max_dist {int} -- Maximum distance until butterflies disappear completely - dz {int} -- Difference of Z pos between camera and butterfly - """ - - return clamp((max_dist - dz) / max_dist * 255, 0, 255) - - -def rgb2hsv(r, g, b): - """Conversion between rgb in range 0-255 to hsv""" - return rgb_to_hsv(r / 255, g / 255, b / 255) - - -def hsv2rgb(h, s, v): - """Conversion between hsv to rgb in range 0-255""" - s = clamp(s) - v = clamp(v) - - r, g, b = hsv_to_rgb(h % 1, s, v) - return ( - int(r * 255), - int(g * 255), - int(b * 255), - ) - - -def random_color(): - """Random RGB color of the rainbow""" - return hsv2rgb(random(), 1, 1) - - -def plane_intersection(p1: vec3, d1: vec3, p2: vec3, d2: vec3): - """ - Compute the line of intersection of the two planes. - - Note: if the two planes are parallel or equal this returns None. - - :param p1: a point in the first plane - :param d1: a vector normal to the first plane - :param p2: a point in the second plane - :param d2: a normal vector of the second plane - :return: None if they are parallel else (p3, d3) - where p3 is a point in the line of intersection - and d3 is the direction of this line - """ - - d1 = normalize(d1) - d2 = normalize(d2) - - if d1 in (d2, -d2): - # planes are parallel - return None - - d3 = cross(d1, d2) - - # d3 and v1 are an orthonormal base of the first plane - v1 = cross(d3, d1) - b = -dot(p1, d2) / dot(v1, d2) - - p3 = p1 + b * v1 - return p3, d3 - - -def cross2d(a, b): - return a.x * b.y - a.y * b.x - - -def line_intersection(p, u, q, v) -> Optional[vec2]: - """ - Compute the intersection of two 2d lines. - - :param p: a point on the first line - :param u: direction of the first line - :param q: a point on the first line - :param v: direction of the first line - :return: None if no intersection - """ - - cross = cross2d(u, v) - if abs(cross) < EPSILON: - return None - - w = p - q - s = cross2d(u, w) / cross - return p + s * u - - -def line_segment_intersection(a, b, p, u) -> Optional[vec2]: - """ - Compute the intersection between a 2d line and a segment. - - :param a: start point of the segment - :param b: end point of the segment - :param p: a point of the line - :param u: the direction of the line - :return: - """ - - v = b - a - cross = cross2d(v, u) - if abs(cross) < EPSILON: - return None - - w = a - p - s = cross2d(v, w) / cross - if 0 <= s <= 1: - return a + s * v - return None diff --git a/run.py b/run_game.py similarity index 54% rename from run.py rename to run_game.py index 370999e..d3e53d0 100755 --- a/run.py +++ b/run_game.py @@ -2,8 +2,8 @@ import sys -if sys.version_info[0] <= 2: - sys.exit("This game requires python 3") +if sys.version_info[0] != 3 or sys.version_info[1] < 7: + sys.exit("This game requires python 3.7") print("Python version:", sys.version) from game import main diff --git a/tests/test_entity.py b/tests/test_entity.py index c5e6d77..d96f796 100755 --- a/tests/test_entity.py +++ b/tests/test_entity.py @@ -5,27 +5,42 @@ import math from game.base.entity import Entity -from game.util.signal import Signal +from game.base.signal import Signal +import weakref import glm -def test_entity_scene(): +def test_scene(): scene = Signal() - e = Entity(None, scene) + ent = Entity(None, scene) - scene.connect(e, weak=False) + slot = scene.connect(ent) + ent.slot = weakref.ref(slot) assert len(scene) == 1 - e.remove() + ent.remove() assert len(scene) == 0 -def test_entity(): +def test_scene_blocked(): - e = Entity(None, None) - e.position = (1, 2, 3) + scene = Signal() + ent = Entity(None, scene) + + scene.blocked += 1 + slot = scene.connect(ent) + scene.blocked -= 1 + ent.slot = weakref.ref(slot) + + assert len(scene) == 0 + scene.refresh() + assert len(scene) == 1 + + scene.blocked += 1 + ent.remove() + scene.blocked -= 1 - assert math.isclose(e.position.x, 1) - assert math.isclose(e.position.y, 2) - assert isinstance(e.position, glm.vec3) + assert len(scene) == 1 + scene.refresh() + assert len(scene) == 0 diff --git a/tests/test_signal.py b/tests/test_signal.py index f19c463..2f5ce55 100755 --- a/tests/test_signal.py +++ b/tests/test_signal.py @@ -3,7 +3,7 @@ sys.path.append("..") -from game.util.signal import Signal +from game.base.signal import Signal def test_signal(): diff --git a/tests/test_when.py b/tests/test_when.py index 6fb6b05..8b2bf27 100755 --- a/tests/test_when.py +++ b/tests/test_when.py @@ -5,7 +5,7 @@ sys.path.append("..") -from game.util.when import When +from game.base.when import When from game.constants import EPSILON @@ -47,7 +47,9 @@ def test_once(): slot = s.once(2, lambda: c.increment()) s.update(1) assert c.x == 0 - s.update(1) + s.update(0.5) + assert c.x == 0 + s.update(0.5) assert c.x == 1 s.update(10) assert c.x == 1 @@ -60,7 +62,7 @@ def test_when_fade(): c = Counter() s = When() - s.fade(1, lambda t: c.increment(t), None, weak=False) + a = s.fade(1, (0, 1), lambda t: c.increment(t), None) s.update(0.2) @@ -72,7 +74,7 @@ def test_when_fade2(): c = Counter() s = When() - a = s.fade(1, lambda t: c.increment(t), ease=None, weak=False) + a = s.fade(1, (0, 1), lambda t: c.increment(t)) assert len(s) == 1 s.update(0.1) @@ -87,3 +89,14 @@ def test_when_fade2(): # because of interpolation assert c.x == pytest.approx(0.3, EPSILON) assert len(s) == 1 + + +def test_when_fade_range(): + + c = Counter() + s = When() + + a = s.fade(2, (2, 3), lambda t: c.increment(t)) + + s.update(1) + assert math.isclose(c.x, 2.5)