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/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/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/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/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/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/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 e9c8e96..8bb9da9 100755 --- a/game/__init__.py +++ b/game/__init__.py @@ -1,10 +1,19 @@ #!/usr/bin/python import sys -from game.base.app import App 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/base/app.py b/game/base/app.py index 6177dd3..014925d 100755 --- a/game/base/app.py +++ b/game/base/app.py @@ -6,17 +6,26 @@ from game.base.inputs import Inputs from game.base.signal import Signal -from game.constants import SPRITES_DIR +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} + STATES = { + "intro": Intro, + "game": Game, + "menu": Menu, + "intermission": Intermission, + "credits": Credits, + } # MAX_KEYS = 512 def __init__(self, initial_state): @@ -25,12 +34,14 @@ def __init__(self, initial_state): 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 @@ -38,6 +49,7 @@ def __init__(self, initial_state): self.inputs = Inputs() self.time = 0 self.dirty = True + self.data = {} # data persisting between modes # self.keys = [False] * self.MAX_KEYS self._state = None @@ -56,7 +68,7 @@ def load(self, filename, resource_func): return r return self.cache[filename] - def load_img(self, filename, scale=1): + 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. @@ -69,10 +81,12 @@ 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, (w * scale, h * scale)) + 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), load_fn) + return self.load((filename, scale, flipped), load_fn) # def pend(self): @@ -99,9 +113,9 @@ def run(self): 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 + # if dt < 0.001: + # time.sleep(1 / 300) + # continue # accumulate dt for skipped frames last_t = cur_t accum += dt @@ -111,10 +125,6 @@ def run(self): frames = 0 accum -= 1 - # dt = self.clock.tick(0) / 1000 - # print(t) - - # time.sleep(0.0001) events = pygame.event.get() for event in events: if event.type == pygame.QUIT: @@ -126,6 +136,9 @@ def run(self): 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 @@ -187,6 +200,20 @@ 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 index a5748be..96d401d 100644 --- a/game/base/being.py +++ b/game/base/being.py @@ -1,6 +1,10 @@ #!/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): @@ -8,17 +12,67 @@ class Being(Entity): An entity with HP """ - def __init__(self, app, scene, filename=None): - super().__init__(app, scene, filename) + def __init__(self, app, scene, filename=None, **kwargs): + super().__init__(app, scene, filename, **kwargs) self.solid = True - self.hp = 1.0 + self.hp = 1 + self.stats = None + self.alive = True # prevent mutliple kill() + self.friendly = False + self.stats = Stats() - def hurt(self, dmg, bullet, player): + 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 - self.hp -= dmg - if self.hp <= 0: - player.score += max(int(dmg), 1) - self.kill(dmg, bullet, player) - return dmg - return dmg + 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 index 73c8a7d..8120c76 100644 --- a/game/base/enemy.py +++ b/game/base/enemy.py @@ -4,5 +4,6 @@ class Enemy(Being): - def __init__(self, app, scene): - super().__init__(app, scene) + 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 b216f9d..6c3114f 100755 --- a/game/base/entity.py +++ b/game/base/entity.py @@ -1,6 +1,7 @@ #!/usr/bin/python from typing import TYPE_CHECKING +import pygame from glm import ivec2 from pygame.surface import SurfaceType @@ -8,10 +9,12 @@ from game.base.signal import Signal, SlotList from game.constants import * from os import path + from game.util import * if TYPE_CHECKING: from game.base.app import App + from game.entities.ai import AI class Entity: @@ -26,32 +29,26 @@ def __init__(self, app, scene, filename=None, **kwargs): self.scene = scene self.slot = None # weakref self.slots = SlotList() - self._life = kwargs.get("life") + 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._surface = None self.removed = False - self.parent = kwargs.get("parent") + self.parent = kwargs.pop("parent", None) self.sounds = {} - self.particle = kwargs.get("particle") - + self.particle = kwargs.pop("particle", None) + self.visible = True self._script_func = False - self._script = None - script = kwargs.get("script") + script = kwargs.pop("script", None) + self.script = None # main script - if callable(self): - # use __call__ as script - self._script = Script(self.app, self, self, use_input=False) - assert not isinstance(script, str) # only one script allowed - elif isinstance(script, str): - # load script from string 'scripts/' folder - self._script = Script(self.app, self, script, use_input=False) - - self._position = kwargs.get("position") or vec3(0) - self.velocity = kwargs.get("velocity") or vec3(0) - self.acceleration = kwargs.get("acceleration") or vec3(0) + 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 @@ -64,18 +61,46 @@ def __init__(self, app, scene, filename=None, **kwargs): self.filename = filename if filename: - self._surface = self.app.load_img(filename) - self.size = estimate_3d_size(self._surface.get_size()) + 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.size = vec3(0) + 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""" 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})" + return f"{self.__class__.__name__}(pos: {self.position}, id: {id(self)})" # def once(self, duration, func) # """ @@ -109,9 +134,21 @@ def position(self, v): if v is None: v = vec3(0) + if v.x != v.x: + raise ValueError + self._position = vec3(*v) self.on_move() + @property + def velocity(self): + return self._velocity + + @velocity.setter + def velocity(self, value): + assert value == value + self._velocity = value + def remove(self): if not self.removed: # for slot in self.slots: @@ -164,6 +201,7 @@ def play_sound(self, filename, callback=None, *args): 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) @@ -174,30 +212,55 @@ def play_sound(self, filename, callback=None, *args): return sound, channel, slot def update(self, dt): + + # 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: + if self.life is not None: + self.life -= dt + if self.life <= 0: self.remove() return - if self._script: # Script object - self._script.update(dt) + 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) + ) if self.slots: self.slots._slots = list( filter(lambda slot: not slot.once or not slot.count, self.slots._slots) ) - def render(self, camera, surf=None): + 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 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 surf: SurfaceType = surf or self._surface if not surf: @@ -207,8 +270,8 @@ def render(self, camera, surf=None): 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(self.position + world_half_diag) - pos_bl = camera.world_to_screen(self.position - world_half_diag) + 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 @@ -218,14 +281,26 @@ def render(self, camera, surf=None): 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 size.x > 0: - surf = pygame.transform.scale(surf, ivec2(size)) - - surf.set_alpha(fade) - surf.set_colorkey(0) + if not scale or 400 > size.x > 0 or big: + if scale: + # print(ivec2(size)) + 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)) # if size.x > 150: diff --git a/game/base/inputs.py b/game/base/inputs.py index c3dd107..b0d4e4f 100644 --- a/game/base/inputs.py +++ b/game/base/inputs.py @@ -1,27 +1,120 @@ -from typing import Dict, Union +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: keycodes of the button + :param keys: any number of keycodes or ButtonInputs """ - self._keys = set(keys) - self._pressed = 0 + + 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._while_pressed = Signal() - self._while_released = Signal() self._on_press = Signal() self._on_release = Signal() self._on_double_press = Signal() @@ -47,14 +140,6 @@ def update(self, dt): self._always(self) - if self._while_pressed: - if self._pressed: - self._while_pressed(self) - - if self._while_released: - if not self._pressed: - self._while_released(self) - if self.just_pressed: self._on_press(self) @@ -82,35 +167,33 @@ def event(self, events): self.just_double_pressed = False self.just_released = False + old_pressed = self.pressed for event in events: - if event.type == pygame.KEYDOWN: - if event.key in self._keys: - self._pressed += 1 - - if self._pressed == 1: - self.press_time = 0 - self.just_pressed = True - if self.double_pressed: - self.just_double_pressed = True - - if event.type == pygame.KEYUP: - if event.key in self._keys: - self._pressed -= 1 - - 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 + 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 self._pressed > 0 + return sum(self._pressed.values(), 0) > 0 @property def double_pressed(self): @@ -120,12 +203,6 @@ def double_pressed(self): def always_call(self, callback): return self._always.connect(callback) - def while_pressed(self, callback): - return self._while_pressed.connect(callback) - - def while_released(self, callback): - return self._while_released.connect(callback) - def on_press(self, callback): return self._on_press.connect(callback) @@ -143,8 +220,6 @@ def on_press_repeated(self, callback, delay): for two different things. """ - # It isn't possible to set it directly, I don't know why - slot = self._repeat.connect(callback) slot.delay = delay slot.repetitions = 0 @@ -154,10 +229,6 @@ 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._while_press: - self._while_press.disconnect(callback) - if callback in self._while_release: - self._while_release.disconnect(callback) if callback in self._on_press: self._on_press.disconnect(callback) if callback in self._on_release: @@ -169,29 +240,45 @@ def disconnect(self, callback): class Axis: - def __init__(self, negative, positive, axis=()): + def __init__(self, negative, positive, *axis, smooth=0.1): """ An input axis taking values between -1 and 1. - Callbacks are set and disconnectd with += and -= + Callbacks are disconnected with -= :param negative: keycode or list of keycodes :param positive: keycode or list of keycodes - :param axis: not implemented yet + :param axis: any number of JoyAxis + :param smooth: Duration (s) to smooth values """ - self._negative = {negative} if isinstance(negative, int) else set(negative) - self._positive = {positive} if isinstance(positive, int) else set(positive) - self._axis = {axis} if isinstance(axis, int) else set(axis) + 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, -1, 1) + return clamp(self._value + self._axis_value, -1, 1) def always_call(self, callback): return self._callbacks.connect(callback) @@ -201,24 +288,57 @@ def __isub__(self, 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: - if event.type == pygame.KEYDOWN: - if event.key in self._negative: - self._value -= 1 - if event.key in self._positive: - self._value += 1 - - if event.type == pygame.KEYUP: - if event.key in self._negative: - self._value += 1 - if event.key in self._positive: - self._value -= 1 - - # TODO: Implement joystick axis + 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]]): @@ -227,7 +347,11 @@ def update(self, dt): for inp in self.values(): inp.update(dt) - def event(self, event): + def event(self, events): """Actualize buttons and axis.""" for inp in self.values(): - inp.event(event) + inp.event(events) + + if DEBUG: + for event in events: + print(event) diff --git a/game/base/script.py b/game/base/script.py index 5315bf2..4290479 100644 --- a/game/base/script.py +++ b/game/base/script.py @@ -3,6 +3,7 @@ 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 @@ -20,13 +21,15 @@ def __init__(self, app, ctx, script, use_input=True, script_args=None): 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.key_down = set() - self.key_up = set() + 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) @@ -40,6 +43,15 @@ def __init__(self, app, ctx, script, use_input=True, script_args=None): 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 @@ -48,9 +60,14 @@ def resume(self): def event(self, ev): if ev.type == pygame.KEYDOWN: - self.key_down.add(ev.key) + self.keys_down.add(ev.key) + self.keys.add(ev.key) elif ev.type == pygame.KEYUP: - self.key_up.add(ev.key) + self.keys_up.add(ev.key) + try: + self.keys.remove(ev.key) + except KeyError: + pass def running(self): return self._script is not None @@ -66,8 +83,19 @@ def key(self, k): assert self.event_slot # input needs to be enabled (default) if isinstance(k, str): - return self.key_down[ord(k)] - return self.key_down[k] + 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 @@ -77,23 +105,23 @@ def key_up(self, k): assert self.event_slot # input needs to be enabled (default) if isinstance(k, str): - return self.key_up[ord(k)] - return self.key_up[k] + 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.key_down - - 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 + # 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): @@ -101,25 +129,40 @@ def script(self): @script.setter def script(self, script=None): - print("Script:", script, self.script_args) + # print("Script:", script, self.script_args) self.slots = [] self.paused = False if isinstance(script, str): - run = importlib.import_module("game.scripts." + script).run - self.inside = True - if self.script_args: - self._script = run(*self.script_args, self) + 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._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) + 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: @@ -141,8 +184,8 @@ def sleep(self, t): def update(self, dt): - # scripts needs this - self.dt = dt + # accumulate dt between yields + self.dt += dt self.when.update(dt) @@ -171,12 +214,20 @@ def update(self, dt): pass except StopIteration: - print("Script Finished") + # print("Script Finished") # traceback.print_exc() self._script = None - except Exception: - 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 @@ -184,5 +235,6 @@ def update(self, dt): # clear accumulated keys self.key_down = set() self.key_up = set() + self.dt = 0 return ran_script diff --git a/game/base/signal.py b/game/base/signal.py index 38172e3..aaf804d 100755 --- a/game/base/signal.py +++ b/game/base/signal.py @@ -7,12 +7,18 @@ 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)): @@ -45,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() @@ -60,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 @@ -68,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 @@ -82,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 @@ -92,10 +103,17 @@ def __del__(self): class Signal: 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) @@ -103,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: @@ -117,11 +135,11 @@ def __call__(self, *args): def clean(self): if self.blocked == 0: for wref in self.slots: - if isinstance(wref, weakref.ref): + if type(wref) == weakref.ref: slot = wref() if not slot: self.disconnect(wref) - elif isinstance(slot.func, weakref.ref): + elif type(slot.func) == weakref.ref: wfunc = slot.func() if not wfunc: self.disconnect(wref) @@ -135,11 +153,17 @@ def refresh(self): # old name 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 @@ -147,12 +171,12 @@ def each(self, func, *args): 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 @@ -162,19 +186,27 @@ def each_slot(self, func, *args): self.clean() def __iadd__(self, func): - return self.connect(func, weak=False) + self.connect(func, weak=False) + return self def __isub__(self, func): - return self.disconnect(func, weak=False) + 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)) @@ -188,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) @@ -217,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 @@ -234,7 +266,7 @@ def disconnect(self, slot): value = slot for i in range(len(self.slots)): slot = self.slots[i] - if isinstance(slot, weakref.ref): + if type(slot) == weakref.ref: wref = slot slot = slot() if not slot: @@ -252,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 e6c52b5..9eaa473 100755 --- a/game/base/state.py +++ b/game/base/state.py @@ -1,27 +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, script=None, use_input=True, **kwargs): + 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 = Script(self.app, self, self, use_input) - assert not isinstance(script, str) # only one script allowed - elif isinstance(script, str): - # load script from string 'scripts/' folder - self._script = Script(self.app, self, script, use_input) - else: - self._script = None + self.script = self + self.scripts += self def update(self, dt): - if self._script: - self._script.update(dt) + + 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/base/when.py b/game/base/when.py index 2662d07..5e766c9 100755 --- a/game/base/when.py +++ b/game/base/when.py @@ -30,7 +30,6 @@ def update_slot(self, slot, dt): if slot.start_t != 0: # not infinite timer slot.t -= dt - # print(slot.t) if slot.fade: slot.t = max(0.0, slot.t) p = 1.0 - (slot.t / slot.start_t) @@ -43,10 +42,13 @@ def update_slot(self, slot, dt): ) ) 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() @@ -55,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): """ diff --git a/game/constants.py b/game/constants.py index 0ba980d..9b54247 100755 --- a/game/constants.py +++ b/game/constants.py @@ -5,6 +5,8 @@ """ import os +import sys + import glm import pygame @@ -16,17 +18,20 @@ 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_PATH = "cloud.png" +CLOUD_IMAGE_PATHS = [f"cloud{i}.png" for i in range(1, 8)] ORANGE = (255, 165, 0) GREEN = (141, 178, 85) GRAY = (100, 100, 100) -BACKGROUND = pygame.Color("lightblue") +BACKGROUND = (77, 143, 172) # we're in "2d" so X and Y basis vectors should be 2d # 3d is optional in positions and velocities @@ -36,9 +41,12 @@ EPSILON = 0.0001 # for floating point comparisons GROUND_HEIGHT = -300 -PLAYER_SPEED = glm.vec3(80, 80, -200) -BULLET_SPEED = 4000 -BULLET_OFFSET = glm.vec3(0, -20, -10) +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 """ @@ -46,3 +54,8 @@ 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 9d2fabd..4848c4e 100644 --- a/game/entities/bullet.py +++ b/game/entities/bullet.py @@ -1,26 +1,60 @@ -#!/usr/bin/env python +##!/usr/bin/env python # from .abstract.entity import Entity -from game.constants import * +from glm import normalize + +from game.base.being import Being from game.base.entity import Entity -from glm import vec3, normalize -from game.entities.butterfly import Butterfly +from game.constants import * class Bullet(Entity): def __init__( - self, app, scene, parent, position, direction, damage, img=BULLET_IMAGE_PATH + self, + app, + scene, + parent, + position, + direction, + damage=1, + img=BULLET_IMAGE_PATH, + speed=BULLET_SPEED, + **kwargs ): - - velocity = normalize(direction) * BULLET_SPEED + self.speed = speed + velocity = normalize(direction) * speed super().__init__( - app, scene, BULLET_IMAGE_PATH, position=position, velocity=velocity, life=1, + 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 = 1000 # to prevent tunneling + self.size.z = BULLET_SIZE # to prevent tunneling self.parent = parent # whoever shot the bullet def collision(self, other, dt): - if isinstance(other, Butterfly): - other.hurt(self.damage, self, self.parent) - self.remove() + # 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 27bc799..285cec8 100755 --- a/game/entities/butterfly.py +++ b/game/entities/butterfly.py @@ -1,48 +1,52 @@ from os import path -import pygame -from glm import ivec2 - from game.base.enemy import Enemy -from game.base.entity import Entity - -from game.constants import Y, SOUNDS_DIR, SPRITES_DIR, ORANGE, FULL_FOG_DISTANCE, GRAY +from game.constants import * from game.entities.camera import Camera from game.util import * -import random 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) size = self.frames[0].get_size() - self.size = vec3(*size, min(size)) - self.position = pos + self.collision_size = self.size = vec3(*size, min(size)) self.time = 0 self.frame = 0 + self.damage = 1 def get_animation(self, color): + 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_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) @@ -64,39 +68,29 @@ def get_animation(self, color): def fall(self): self.velocity = -Y * 100 - self.life = 2 + self.life = 2 # remove in 2 seconds + self.alive = False def kill(self, damage, bullet, player): + + if not self.alive: + return False + # Butterfly will turn gray when killed self.frames = self.get_animation(GRAY) - # TODO: This is supposed to be an explosion - 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, - particle=True, - ), - ) + self.scripts = [] + self.explode() self.play_sound("butterfly.wav") self.fall() + return True # def hurt(self, damage, bullet, player): # return super().hurt(damage, bullet, player) def update(self, dt): - super().update(dt) self.time += dt * 10 + super().update(dt) def render(self, camera: Camera): diff --git a/game/entities/camera.py b/game/entities/camera.py index 4ca80d4..9c666bf 100755 --- a/game/entities/camera.py +++ b/game/entities/camera.py @@ -70,12 +70,13 @@ def world_to_screen(self, world_pos: vec3) -> Union[vec2, 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 @@ -145,4 +146,4 @@ def _rotate(vec, angle, axis): axis = Vector3(axis) vec.rotate_ip(angle * 180 / pi, axis) - return vec3(vec) + return vec3(*vec) diff --git a/game/entities/cloud.py b/game/entities/cloud.py index cf30a8a..dd3e1d0 100644 --- a/game/entities/cloud.py +++ b/game/entities/cloud.py @@ -1,8 +1,9 @@ -from game.base.entity import Entity -from game.constants import SPRITES_DIR, CLOUD_IMAGE_PATH, SHIP_IMAGE_PATH +from random import randint, choice + from glm import vec3 -from random import randint -import os + +from game.base.entity import Entity +from game.constants import CLOUD_IMAGE_PATHS class Cloud(Entity): @@ -14,4 +15,6 @@ class Cloud(Entity): def __init__(self, app, scene, pos: vec3, z_vel: float): vel = vec3(randint(0, 15) * Cloud.hdg, 0, z_vel) - super().__init__(app, scene, SHIP_IMAGE_PATH, position=pos, velocity=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 7ac03b3..83ae979 100644 --- a/game/entities/ground.py +++ b/game/entities/ground.py @@ -1,8 +1,15 @@ +#!/usr/bin/env python +from functools import lru_cache + import pygame -from glm import vec3 +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 @@ -11,6 +18,48 @@ 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) @@ -53,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 index 5d6e174..59c9a10 100644 --- a/game/entities/message.py +++ b/game/entities/message.py @@ -3,9 +3,10 @@ import random import pygame -from glm import ivec2, ivec4 +from glm import ivec2, ivec4, vec3 from game.base.entity import Entity +from game.util import * from game.constants import * @@ -14,33 +15,49 @@ class Message(Entity): A single message in world space """ - def __init__(self, app, scene, text): - super().__init__(app, scene) + def __init__(self, app, scene, text, color, **kwargs): + super().__init__(app, scene, **kwargs) self.app = app self.scene = scene - self.text = text + self.collision_size = self.size = vec3(24 * len(text), 24, 150) self.font_size = ivec2(24, 24) - font_fn = "data/PressStart2P-Regular.ttf" + 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), ) - # dirty flags for lazy redrawing - self.dirty = True - - # set entity surface - self._surface = pygame.Surface( - self.app.size, pygame.SRCALPHA, 32 - ).convert_alpha() - - self.bg_color = ivec4(255, 255, 255, 0) # transparent by default - self.shadow_color = ivec4(120, 120, 120, 0) - self.shadow2_color = ivec4(0, 0, 0, 0) - - def update(self, t): - self.position = self.app.state.player.position + Z * 100 - pass + 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 8d3a034..ca36489 100755 --- a/game/entities/player.py +++ b/game/entities/player.py @@ -1,89 +1,205 @@ #!/usr/bin/python +import math +import random from typing import List -import pygame -from glm import vec3, sign, length +from glm import ivec2, vec2, length, vec3 from pygame.surface import SurfaceType -from copy import copy -import random -import weakref -from game.base.entity import Entity from game.base.being import Being -from game.base.inputs import Inputs, Axis +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.base.enemy import Enemy -from game.entities.weapons import Weapon, Pistol, MachineGun, LaserGun +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): + def __init__(self, app, scene, speed=PLAYER_SPEED, level=0): super().__init__(app, scene, filename=SHIP_IMAGE_PATH) self.game_state = self.scene.state - self.score = 0 - self.hp = 3 + # persistant stats for score screen + self.stats = self.app.data["stats"] = self.app.data.get("stats", Stats()) + + self.scene.player = self + + 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.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.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.weapons: List[Weapon] = [ - self.scene.add(gun(app, scene, self)) - for gun in (Pistol, MachineGun, LaserGun) - ] + 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 - damage = min(self.hp, damage) # calc effective damage (not more than hp) - self.hp -= damage - if self.hp <= 0: - self.kill(damage, bullet, enemy) - # if self.hp < 3: - # self.smoke_event = scene.when.every(1, self.smoke) - return damage + 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): - self.score += other.hp + 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 = camera.screen_size / 2 + screen_center = vec2(camera.screen_size) / 2 crosshair_radius = self.crosshair_surf.get_width() / 2 - for entity in self.scene.slots: + + # 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, Butterfly): + if ( + isinstance(entity, Enemy) + and camera.distance(entity.position) < AIM_MAX_DIST + ): center = camera.world_to_screen(entity.position) if ( center @@ -93,32 +209,74 @@ def find_enemy_in_crosshair(self): return entity def write_weapon_stats(self): - wpn = self.weapons[self.current_weapon] - # extra space here to clear terminal - if wpn.max_ammo < 0: - ammo = " " * 5 # spacing - else: - ammo = f"{wpn.ammo}/{wpn.max_ammo} " - - self.game_state.terminal.write(wpn.letter + " " + ammo, (0, 21), wpn.color) - - self.game_state.terminal.write("♥ " * self.hp + " " * (3 - self.hp), 0, "red") + if not self.alive: + return - # 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) + 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}" + + 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 + ) + + # 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): @@ -132,6 +290,9 @@ def find_aim(self): return aim def fire(self, button): + if not self.alive: + return + if not button.pressed: return @@ -140,7 +301,8 @@ def fire(self, button): self.current_weapon = 0 if self.weapon.fire(self.find_aim()): - self.play_sound("shoot.wav") + self.weapon_flash = 1 + self.play_sound(self.weapon.sound) def update(self, dt): @@ -153,55 +315,118 @@ def update(self, dt): self.velocity.y = min(0, self.velocity.y) self.position.y = 300 - super().update(dt) + if not self.alive: + self.velocity.x = 0 + self.velocity.y = 0 - def heading(self): + 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) - 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 + self.score_flash = self.score_flash - dt + self.weapon_flash = self.weapon_flash - dt + self.health_flash = self.health_flash - dt + + super().update(dt) - start = camera.rel_to_world(BULLET_OFFSET) - Z * self.fire_offset - direction = aim - start - return start, aim, direction - - # def smoke(self): - - # start, aim, direction = self.heading() - - # self.scene.add( - # Entity( - # self.app, - # self.scene, - # "bullet.png", - # position=self.position - start, - # # velocity=( - # # vec3(*(random.random() for r in range(3))) - vec3(0.5) - # # ) * 10, - # life=1, - # particle=True, + 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 = sign(self.velocity.xy) + direction = self.velocity.xy / self.speed.xy rect.center += direction * (10, -10) - self.app.screen.blit(self._surface, rect) + if self.visible: + # stretch player graphic + sz = ivec2(*self._surface.get_size()) - # Crosshair - rect = self.crosshair_surf.get_rect() - rect.center = self.app.size / 2 + img = self._surface + if self.velocity: + sz.y += self.velocity.y / self.speed.y * 10 + img = pygame.transform.scale(self._surface, sz) - if self.find_enemy_in_crosshair(): - self.app.screen.blit(self.crosshair_surf_green, rect) - else: - self.app.screen.blit(self.crosshair_surf, rect) + 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/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 90b2ac2..6d2a94b 100755 --- a/game/entities/terminal.py +++ b/game/entities/terminal.py @@ -3,21 +3,17 @@ 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=pygame.Color(255, 255, 255, 0), - offset=ivec2(0, 0), + self, text, imgs, pos=ivec2(0, 0), color=(255, 255, 255, 0), offset=ivec2(0, 0), ): self.imgs = imgs self.text = text @@ -27,13 +23,13 @@ def __init__( 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) + self.font_size = ivec2(size or 24) self.spacing = ivec2(0) font_fn = path.join(FONTS_DIR, "PressStart2P-Regular.ttf") @@ -123,7 +119,7 @@ def write( self, text, pos=(0, 0), - color=(255, 255, 255, 0), + color=vec4(1, 1, 1, 0), offset=(0, 0), align=-1, length=0, @@ -169,8 +165,8 @@ def write( 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: @@ -195,10 +191,18 @@ def write( self.dirty = True def write_center( - self, text, pos=0, color=(255, 255, 255, 0), offset=(0, 0), length=0 + 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 @@ -207,12 +211,11 @@ def write_center( pos = ivec2(pos[0], pos[1]) # print(pos) - pos.x -= self.size.x / 2 + 1 + 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=(255, 255, 255, 0), offset=(0, 0), length=0 - ): + def write_right(self, text, pos=0, color=vec4(1, 1, 1, 0), offset=(0, 0), length=0): """ write() to screen right side """ @@ -223,7 +226,7 @@ def write_right( else: pos = ivec2(pos[0], pos[1]) - pos.x += self.size.x - 1 + pos.x += self.size.x - 2 return self.write(text, pos, color, offset, 1, len(text)) def scramble(self): @@ -266,6 +269,11 @@ def render(self, camera): ), ) + 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: diff --git a/game/entities/weapons.py b/game/entities/weapons.py index e81e6e4..7a4ebd5 100644 --- a/game/entities/weapons.py +++ b/game/entities/weapons.py @@ -1,30 +1,36 @@ import pygame -from glm import vec3, normalize +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 +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): - def __init__(self, player, letter, color, ammo, speed, damage, app, scene): + 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 letter: Letter to display it - :param color: Color for the letter display :param ammo: Max ammunition :param speed: bullets per second :param damage: damage per bullet """ super().__init__(app, scene, parent=player) - self.letter = letter - self.color = color - self.max_ammo = ammo # max ammo - self.ammo = ammo # current ammo - self.cooldown = 1 / speed - self.damage = damage + + # 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") @@ -54,8 +60,13 @@ def fire(self, aim): class Pistol(Weapon): - def __init__(self, app, scene, player): - super(Pistol, self).__init__(player, "P", "yellow", -1, 3, 1, app, scene) + 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 @@ -74,8 +85,13 @@ def get_bullets(self, aim): class MachineGun(Weapon): - def __init__(self, app, scene, player): - super(MachineGun, self).__init__(player, "M", "orange", 25, 6, 1, app, scene) + 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 @@ -96,36 +112,131 @@ def get_bullets(self, aim): class Laser(Bullet): def __init__(self, app, scene, parent, position, direction, length, color, damage): - super().__init__(app, scene, parent, position, direction, damage) + super().__init__( + app, scene, parent, position, direction, damage, speed=LASER_SPEED + ) self.color = pygame.Color(color) - self.length = length + 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.length + self.position + normalize(self.velocity) * self.size.z ) pygame.draw.line(self.app.screen, self.color, p1, p2, 4) class LaserGun(Weapon): - def __init__(self, app, scene, player): - super(LaserGun, self).__init__(player, "L", "red", 20, 2, 2, app, scene) + 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 - for x in range(5): - yield Laser( - self.app, - self.scene, - self.parent, - start + camera.direction * 100 * x, - direction, - 100, - "red", - self.damage / 5, - ) + 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/scene.py b/game/scene.py index f7a1f20..b061084 100755 --- a/game/scene.py +++ b/game/scene.py @@ -1,35 +1,88 @@ #!/usr/bin/env python import functools -from game.base.signal import Signal, Slot + +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 +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): - MAX_PARTICLES = 16 - 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.when = When() + 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.sounds = {} + # self.script_fn = script - # self.event_slot = self.app.on_event.connect(self.event) + # self.event_slot = self.app.on_event.connect(self.even) # self.script_resume_condition = None @@ -48,17 +101,194 @@ def __init__(self, app, state, script=None, script_args=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._script = None + self.rain_slot = None - @property - def when(self): - if self._script and self._script.running(): - # Sanity Check: - # Don't use scene.when() when inside a script. - # Use script.when() - assert not self._script.inside - return self._when + 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.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): @@ -76,6 +306,23 @@ def script(self, fn): 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 + + 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 @@ -83,34 +330,6 @@ def music(self, filename): pygame.mixer.music.load(path.join(MUSIC_DIR, filename)) pygame.mixer.music.play(-1) - def color(self, c): - """ - Given a color string, a pygame color, or vec3, - return that as a normalized vec4 color - """ - # print(c) - if isinstance(c, str): - c = vec4(*pygame.Color(c)) / 255.0 - elif isinstance(c, pygame.Color): - c = vec4(*c) / 255.0 - elif isinstance(c, vec3): - c = vec4(*c, 0) - elif isinstance(c, (float, int)): - c = vec4(c, c, c, 0) - return c - - def mix(self, 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(self.color(a), self.color(b), t) - def on_collision_connect(self, A, B, func, once=True): """ during collision (touching) @@ -161,7 +380,7 @@ def on_collision_leave(self, A, B, func, once=True): def add(self, entity): slot = self.connect(entity, weak=False) entity.slot = weakref.ref(slot) - self.slots.append(slot) + # self.slotlist += slot return entity @property @@ -170,24 +389,49 @@ def sky_color(self): @sky_color.setter def sky_color(self, c): - self._sky_color = self.color(c) + 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 # for scripts to call when.fade(1, set_sky_color) def set_sky_color(self, c): - self._sky_color = self.color(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.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): + # self.slotlist -= entity super().disconnect(entity) - # slot = entity.slot - # if slot: - # entity.slot = None - # if isinstance(slot, weakref.ref): - # wslot = slot - # slot = wslot() - # if not slot: - # return - - # super().disconnect(slot) # def resume(self): # self.script_paused = False @@ -208,7 +452,7 @@ def update_collisions(self, dt): if not a or not a.solid: continue - if self.invalid_size(a.size): + if self.invalid_size(a.collision_size): continue # for each slot, loop through each slot @@ -221,13 +465,13 @@ def update_collisions(self, dt): if not a.has_collision and not b.has_collision: continue - if self.invalid_size(b.size): + if self.invalid_size(b.collision_size): continue - a_min = a.position - a.size / 2 - a_max = a.position + a.size / 2 - b_min = b.position - b.size / 2 - b_max = b.position + b.size / 2 + 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 @@ -247,19 +491,56 @@ def update_collisions(self, dt): 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) self.update_collisions(dt) self.refresh() + # main level script if self._script: self._script.update(dt) + # 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 @@ -272,22 +553,18 @@ def update(self, dt): e = slot.get() if e.particle: particle_count += 1 - if particle_count >= self.MAX_PARTICLES: + if particle_count >= self.max_particles: slot.disconnect() self.blocked -= 1 self.clean() def render(self, camera): # call render(camera) on all scene entities - if self.sky_color: - self.app.screen.fill( - pygame.Color( - int(self._sky_color[0] * 255), - int(self._sky_color[1] * 255), - int(self._sky_color[2] * 255), - int(self._sky_color[3] * 255), - ) - ) + + 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/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/level.py b/game/scripts/level.py index ecdf4b5..f604619 100644 --- a/game/scripts/level.py +++ b/game/scripts/level.py @@ -1,44 +1,121 @@ -import pygame -from glm import vec3, ivec2 -from pygame.camera import Camera -from random import randint +from contextlib import contextmanager +from math import cos, sin, pi, tau -from game.constants import FULL_FOG_DISTANCE +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.cloud import Cloud +from game.entities.powerup import Powerup +from game.scene import Scene from game.util import random_color class Level: - sky = "blue" + 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 + 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 - def spawn(self, x: float, y: float): + @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 = camera.rel_to_world( - vec3(x, y, -camera.screen_dist * FULL_FOG_DISTANCE) - * vec3(*camera.screen_size / 2, 1) - ) + pos = vec3( + x, y, camera.position.z - camera.screen_dist * FULL_FOG_DISTANCE + ) * vec3(*camera.screen_size / 2, 1) - self.scene.add( - Butterfly(self.app, self.scene, pos, random_color(), num=self.spawned,) + 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): """ @@ -47,49 +124,214 @@ def pause(self, duration): Next spawn will be exactly after `duration` seconds. """ - return self.script.sleep(duration) + 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.cloudy() + self.scene.ground_color = self.ground + self.scene.music = self.music if self.name: - terminal = self.app.state.terminal - typ = pygame.mixer.Sound("data/sounds/type.wav") + yield from self.slow_type(self.name, 6, "white", 0.1) - left = ivec2((terminal.size.x - len(self.name)) / 2, 5) - for i, letter in enumerate(self.name): - terminal.write(letter, left + (i, 0), "white") - typ.play() - yield self.pause(0.1) + terminal = self.app.state.terminal + terminal.clear(6) - terminal.clear(left[1]) + self.scene.play_sound("message.wav") # blink for i in range(10): # terminal.write(self.name, left, "green") - terminal.write_center(self.name, 5, "green") - terminal.write_center("Go!", 7, "white") + 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(left[1]) + terminal.clear(8) yield self.pause(0.1) - terminal.clear(7) + 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 cloudy(self): - for i in range(20): - x = randint(-2000, 2000) - y = randint(300, 600) - z = randint(-7000, -3000) - pos = vec3(x, y, z) - self.scene.add( - Cloud(self.app, self.scene, pos, self.app.state.player.velocity.z) - ) - 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 2a83c1e..37569ea 100644 --- a/game/scripts/level1.py +++ b/game/scripts/level1.py @@ -5,30 +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 class Level1(Level): - name = "Level 1" - sky = "#59ABE3" + number = 1 + name = "The Butterflies Awaken" + ground = GREEN + sky = Level.night_sky + music = "butterfly.ogg" def __call__(self): + self.scene.cloudy() + self.scene.rocks() + self.scene.stars() + self.scene.rain() + yield from super().__call__() - for _ in range(10): - self.spawn(0, 0) - yield self.pause(1) + self.spawn(0, 0) + yield self.small_pause() + self.spawn(0, 0) + yield self.medium_pause() + + self.square(0.1) + yield self.medium_pause() + + self.square(0.25) + yield self.medium_pause() - self.spawn(0.5, 0.5) - self.spawn(0.5, -0.5) - self.spawn(-0.5, -0.5) - self.spawn(-0.5, 0.5) + for i in range(10): + self.spawn(uniform(-0.3, 0.3), uniform(-0.2, 0.2)) + yield self.small_pause() - yield self.pause(3) + 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.pause(1) - for i in range(1, 5): - self.spawn(i / 10, 0) - self.spawn(-i / 10, 0) - yield self.pause(1) + 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 3db071b..a80019a 100755 --- a/game/states/game.py +++ b/game/states/game.py @@ -1,18 +1,26 @@ #!/usr/bin/env python +import os +import re +from functools import lru_cache + import pygame from glm import vec3, sign from random import randint -from game.base.inputs import Inputs, Axis, Button +from game.scene import Scene +from game.base.inputs import Inputs, Axis, Button, JoyAxis, JoyButton, JoyAxisTrigger from game.base.state import State -from game.constants import GROUND_HEIGHT +from game.constants import CAMERA_OFFSET, SCRIPTS_DIR, DEBUG from game.entities.camera import Camera -from game.entities.ground import Ground from game.entities.player import Player from game.entities.terminal import Terminal -from game.scene import Scene -from game.scripts.level1 import Level1 +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): @@ -23,17 +31,23 @@ def __init__(self, app, state=None): 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, GROUND_HEIGHT)) - self.player = self.scene.add(Player(app, self.scene)) - # self.msg = self.scene.add(Message(self.app, self.scene, "HELLO")) + 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.scene.script = Level1 + # self.scripts += self.score_screen # self.camera.slots.append( # self.player.on_move.connect(lambda: self.camera.update_pos(self.player)) @@ -44,13 +58,89 @@ def __init__(self, app, state=None): 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 - self.terminal.clear(20) - self.terminal.clear(21) + for i in range(9): + self.terminal.clear(13 + i) if not b: self.player.write_weapon_stats() @@ -64,18 +154,21 @@ 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 not self.scene.script or self.scene.script.done(): - self.scene.script = Level1 # restart + if self.scene.script and self.scene.script.done(): + self.app.state = "intermission" + return self.scene.update(dt) self.gui.update(dt) # Update the camera according to the player position # And movement - self.camera.position = self.player.position + 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: @@ -95,17 +188,18 @@ def render(self): # pos_pos = (self.terminal.size.x - len(pos_display), 0) # self.terminal.write(pos_display, pos_pos) - # Render Player's Score - score_display = "Score: {}".format(self.player.score) - score_pos = ( - self.terminal.size.x - len(score_display), - 0, - ) - self.terminal.write(score_display, score_pos) - + # self.debug = True if self.debug: - self.terminal.write("Entities: " + str(len(self.scene.slots)), 20) - self.terminal.write("FPS: " + str(self.app.fps), 21) + 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) self.scene.render(self.camera) self.gui.render(self.camera) @@ -115,10 +209,43 @@ def render(self): 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)) - inputs["vmove"] = Axis((pg.K_DOWN, pg.K_s), (pg.K_UP, pg.K_w)) - inputs["fire"] = Button(pg.K_SPACE, pg.K_RETURN) + 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) + 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): + """ + Called by player when() event after death + """ + # clear terminal + self.scene.clear_type(Enemy) + self.scene.clear_type(Powerup) + + 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 4e17dd2..48aaad3 100644 --- a/game/states/intro.py +++ b/game/states/intro.py @@ -3,10 +3,15 @@ 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.scene import Scene +from game.util import pg_color, random_rgb, random_char, ncolor import pygame import glm -from glm import vec4 +import random +import math +from glm import vec3, vec4, ivec2 class Intro(State): @@ -16,10 +21,54 @@ def __init__(self, app, state=None): 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 @@ -39,69 +88,148 @@ 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 - color = self.scene.color terminal = self.terminal - keys = script.keys - - when.fade(3, scene.__class__.sky_color.setter, (vec4(0), vec4(1))) - a = when.fade( - 3, - (0, 1), - lambda t: scene.set_sky_color( - glm.mix(color("black"), color("darkgray"), t) + + 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" - typ = pygame.mixer.Sound("data/sounds/type.wav") + self.scene.music = "butterfly2.ogg" - msg = "Welcome to Butterfly Destroyers!" - for i in range(len(msg)): - terminal.write(msg[i], (i, 0), "red") - typ.play() - yield script.sleep(0.1) + # 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", + "In the year 20XX, the butterfly", "overpopulation problem has", "obviously reached critical mass.", "The military has decided to intervene.", - "Your mission is simple: murder all the", + "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(m, (x, y * 2 + 3), "white") - typ.play() - yield script.sleep(0.05) - - t = 0 - while True: - - terminal.write_center("Press any key to continue", 20, "green") - - # for x in range(terminal.size.x): - # # print(math.sin(t*20)*20) - # terminal.clear(19) - # terminal.clear(21) - # terminal.offset((x,20), (0, math.sin(t*math.tau*300)*4 - 2)) + 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) - yield script.sleep(0.2) - if len(keys()): - break - - # t += script.dt - - terminal.clear(20) + when = script.when + scene = self.scene + terminal = self.terminal - yield script.sleep(0.2) - if len(keys()): - break + 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/util.py b/game/util.py index d801e04..9ac9817 100755 --- a/game/util.py +++ b/game/util.py @@ -1,10 +1,13 @@ from colorsys import rgb_to_hsv, hsv_to_rgb -from random import random +import random +from functools import lru_cache from typing import Union, Optional -from glm import vec3, normalize, cross, dot, vec2 +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 +from game.constants import EPSILON, DEBUG +import pygame def map_range(val, r1, r2): @@ -53,7 +56,7 @@ def hsv2rgb(h, s, v): def random_color(): """Random RGB color of the rainbow""" - return hsv2rgb(random(), 1, 1) + return hsv2rgb(random.random(), 1, 1) def plane_intersection(p1: vec3, d1: vec3, p2: vec3, d2: vec3): @@ -142,3 +145,142 @@ def estimate_3d_size(size_2d): """ 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/run.py b/run_game.py similarity index 100% rename from run.py rename to run_game.py diff --git a/tests/test_when.py b/tests/test_when.py index 4532313..8b2bf27 100755 --- a/tests/test_when.py +++ b/tests/test_when.py @@ -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