def test_signal(): s = Signal() hello = s.connect(lambda: print("hello ", end=""), weak=False) s.connect(lambda: print("world"), weak=False) assert len(s) == 2 s() # 'hello world' assert s.disconnect(hello) s() # 'world' assert len(s) == 1 s.clear() assert len(s) == 0
def test_signal_weak(): s = Signal() w = s.connect(lambda: print("test")) del w assert len(s) == 0 s() assert len(s) == 0 s = Signal() w = s.connect(lambda: print("test")) del s # slot outlives signal? assert w.sig() is None # it works del w
def test_scene(): scene = Signal() ent = Entity(None, scene) slot = scene.connect(ent) ent.slot = weakref.ref(slot) assert len(scene) == 1 ent.remove() assert len(scene) == 0
def test_scene_blocked(): scene = Signal() ent = Entity(None, scene) scene.blocked += 1 slot = scene.connect(ent) scene.blocked -= 1 ent.slot = weakref.ref(slot) assert len(scene) == 0 scene.refresh() assert len(scene) == 1 scene.blocked += 1 ent.remove() scene.blocked -= 1 assert len(scene) == 1 scene.refresh() assert len(scene) == 0
def test_signal_queue(): # queued connection s = Signal() s.blocked += 1 a = s.connect(lambda: print("queued"), weak=False) assert len(s.queued) == 1 s() # nothing s.blocked -= 1 for slot in s.queued: slot() s.queued = [] s() # "queued" # queued disconnection s.blocked += 1 a.disconnect() assert len(s) == 1 # still attached assert len(s.queued) == 1 s.blocked -= 1 for q in s.queued: q() s.queued = [] assert len(s) == 0
class App: STATES = { "intro": Intro, "game": Game, "menu": Menu, "intermission": Intermission, "credits": Credits, } # MAX_KEYS = 512 def __init__(self, initial_state): """ The main beginning of our application. Initializes pygame and the initial state. """ # pygame.mixer.pre_init(44100, 16, 2, 4096) pygame.init() self.size = ivec2(1920, 1080) / 2 """Display size""" self.cache = {} """Resources with filenames as keys""" pygame.display.set_caption("Butterfly Destroyers") self.screen = pygame.display.set_mode(self.size) self.on_event = Signal() self.quit = False self.clock = pygame.time.Clock() self.inputs = Inputs() self.time = 0 self.dirty = True self.data = {} # data persisting between modes # self.keys = [False] * self.MAX_KEYS self._state = None self.last_state = None self.next_state = initial_state self.process_state_change() def load(self, filename, resource_func): """ Attempt to load a resource from the cache, otherwise, loads it :param resource_func: a function that loads the resource if its not already available in the cache """ if filename not in self.cache: r = self.cache[filename] = resource_func() return r return self.cache[filename] def load_img(self, filename, scale=1, flipped=False): """ Load the image at the given path in a pygame surface. The file name is the name of the file without the full path. Files are looked for in the SPRITES_DIR Results are cached. Scale is an optional integer to scale the image by a given factor. """ def load_fn(): img = pygame.image.load(os.path.join(SPRITES_DIR, filename)) if scale != 1: w, h = img.get_size() img = pygame.transform.scale(img, ivec2(vec2(w, h) * scale)) if flipped: img = pygame.transform.flip(img, True, False) return img return self.load((filename, scale, flipped), load_fn) # def pend(self): # self.dirty = True def run(self): """ Main game loop. Runs until the `quit` flag is set Runs update(dt) and render() of the current game state (default: Game) """ last_t = time.time_ns() accum = 0 self.fps = 0 frames = 0 dt = 0 self.inputs.event([]) while (not self.quit) and self.state: cur_t = time.time_ns() dt += (cur_t - last_t) / (1000 * 1000 * 1000) # if dt < 0.001: # time.sleep(1 / 300) # continue # accumulate dt for skipped frames last_t = cur_t accum += dt frames += 1 if accum > 1: self.fps = frames frames = 0 accum -= 1 events = pygame.event.get() for event in events: if event.type == pygame.QUIT: return 0 self.on_event(event) self.inputs.event(events) if self.state is None: break if DEBUG: print("FRAME, dt =", dt, "FPS,", self.fps) self.inputs.update(dt) if self.update(dt) is False: break if self.render() is False: break dt = 0 # reset to accumulate def add_event_listener(self, obj): slot = self.on_event.connect(obj.event) obj.slots.append(slot) return slot def update(self, dt): """ Called every frame to update our game logic :param dt: time since last frame in seconds :return: returns False to quit gameloop """ if not self.state: return False if self.next_state: self.process_state_change() self.state.update(dt) def render(self): """ Called every frame to render our game state and update pygame display :return: returns False to quit gameloop """ # if not self.dirty: # return # self.dirty = False if self.state is None: return False self.state.render() pygame.display.update() @property def state(self): return self._state @state.setter def state(self, s): """ Schedule state change on next frame """ self.next_state = s def process_state_change(self): """ Process pending state changes """ lvl = None try: lvl = int(self.next_state) pass except ValueError: pass if lvl: stats = self.data["stats"] = self.data.get("stats", Stats()) stats.level = lvl self.next_state = "game" if self.next_state: self._state = self.STATES[self.next_state.lower()](self) self.next_state = None
class Axis: def __init__(self, negative, positive, *axis, smooth=0.1): """ An input axis taking values between -1 and 1. Callbacks are disconnected with -= :param negative: keycode or list of keycodes :param positive: keycode or list of keycodes :param axis: any number of JoyAxis :param smooth: Duration (s) to smooth values """ if isinstance(negative, int): negative = [negative] if isinstance(positive, int): positive = [positive] self._negative = {KeyPress(n): False for n in negative} self._positive = {KeyPress(p): False for p in positive} self._axis = set(axis) self._callbacks = Signal() self._smooth = smooth self.non_zero_time = 0 self.zero_time = 0 # Hold the number of keys pressed self._int_value = 0 # Hold the smoothed number of keys pressed self._value = 0 # Hold the total value of axis, # separately because of different tracking methods self._axis_value = 0 def __str__(self): return f"Axis({self.value})" @property def value(self): return clamp(self._value + self._axis_value, -1, 1) def always_call(self, callback): return self._callbacks.connect(callback) def __isub__(self, callback): return self._callbacks.disconnect(callback) def update(self, dt): """Trigger all callbacks and updates times""" if self._int_value != 0: # Nonzero check is okay as JoyAxis already count the threshold self.non_zero_time += dt self.zero_time = 0 else: self.non_zero_time = 0 self.zero_time += dt if self._smooth <= 0: self._value = self._int_value else: dv = dt / self._smooth if self._int_value > 0: self._value += dv elif self._int_value < 0: self._value -= dv else: if self._value > 0: self._value -= dv else: self._value += dv if abs(self._value) <= dv: # To have hard zeros self._value = 0 self._value = clamp(self._value, -1, 1) self._callbacks(self) def event(self, events): axis_value = 0 any_axis = False for event in events: for pos in self._positive: if pos.match(event): self._positive[pos] = pos.pressed(event) for neg in self._negative: if neg.match(event): self._negative[neg] = neg.pressed(event) for axis in self._axis: if axis.match(event): # We take the most extreme value val = axis.value(event) if abs(val) > abs(axis_value): axis_value = val any_axis = True self._int_value = sum(self._positive.values()) - sum(self._negative.values()) if any_axis: self._axis_value = axis_value
class Button: def __init__(self, *keys): """ A boolean input. :param keys: any number of keycodes or ButtonInputs """ self._keys: Set[ButtonInput] = { KeyPress(key) if isinstance(key, int) else key for key in keys } self._pressed = {} self.just_released = False self.just_pressed = False self.just_double_pressed = False self._always = Signal() self._on_press = Signal() self._on_release = Signal() self._on_double_press = Signal() self._repeat = Signal() # _repeat[callback] = [delay, trigger_count] self.last_press = float("-inf") """Time since last release of the button""" self.press_time = 0 self.dt = 0 # time since last frame """ Time the button has been pressed. If it isn't pressed, it is the duration of the last press. """ def update(self, dt): """Trigger all callbacks and updates times""" self.last_press += dt if self.pressed: self.press_time += dt self.dt = dt self._always(self) if self.just_pressed: self._on_press(self) if self.just_double_pressed: self._on_double_press(self) if self.just_released: self._on_release(self) if self.pressed: self._repeat.blocked += 1 for wref in self._repeat.slots: c = wref() if not c: continue if c.delay * c.repetitions <= self.press_time: # It isn;t possible to set it directly, I don't know why c.repetitions += 1 c(self) self._repeat.blocked -= 1 self._repeat.refresh() def event(self, events): self.just_pressed = False self.just_double_pressed = False self.just_released = False old_pressed = self.pressed for event in events: for key in self._keys: if key.match(event): self._pressed[key] = key.pressed(event) if not old_pressed: if self.pressed: self.press_time = 0 self.just_pressed = True if self.double_pressed: self.just_double_pressed = True else: if not self.pressed: # All keys were just released self.last_press = 0 self.just_released = True for wref in self._repeat.slots: c = wref() if not c: continue c.repetitions = 0 @property def pressed(self): """Whether the button is actually pressed.""" return sum(self._pressed.values(), 0) > 0 @property def double_pressed(self): """Whether the button was just double pressed""" return self.pressed and self.last_press < 0.1 def always_call(self, callback): return self._always.connect(callback) def on_press(self, callback): return self._on_press.connect(callback) def on_release(self, callback): return self._on_release.connect(callback) def on_double_press(self, callback): return self._on_double_press.connect(callback) def on_press_repeated(self, callback, delay): """ Call `callback` when the button is pressed and every `delay` seconds while it is pressed. Note: the same function cannot be a repeat callback for two different things. """ slot = self._repeat.connect(callback) slot.delay = delay slot.repetitions = 0 return slot def disconnect(self, callback): """Remove a callback from all types if present.""" if callback in self._always: self._always.disconnect(callback) if callback in self._on_press: self._on_press.disconnect(callback) if callback in self._on_release: self._on_release.disconnect(callback) if callback in self._on_double_press: self._on_double_press.disconnect(callback) if callback in self._repeat: self._on_double_press.disconnect(callback)
class Scene(Signal): 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.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.even) # self.script_resume_condition = None # The below wrapper is just to keep the interface the same with signal # on_collision.connect -> on_collision_connect # class CollisionSignal: # pass # self.on_collision = CollisionSignal() # self.on_collision.connect = self.on_collision_connect # self.on_collision.once = self.on_collision_once # self.on_collision.enter = self.on_collision_enter # self.on_collision.leave = self.on_collision_leave self._music = None if script: self.script = script # trigger setter self.when.every(1, self.stabilize, weak=False) def iter_entities(self, *types): for slot in self.slots: ent = slot.get() if ent and isinstance(ent, types): yield ent def cloudy(self): if self.has_clouds: return pv = self.player.velocity if self.player else vec3(0) if hasattr(self.app.state, "player"): velz = pv.z else: velz = 0 for i in range(30): x = randint(-3000, 3000) y = randint(0, 300) z = randint(-4000, -1300) pos = vec3(x, y, z) self.add(Cloud(self.app, self, pos, velz)) self.has_clouds = True def lightning_strike(self): oldsky = vec4(self.sky_color) if self.sky_color else None self.sky_color = "white" self.when.once(0.1, lambda oldsky=oldsky: self.set_sky_color(oldsky), weak=False) self.play_sound("lightning.wav") def lightning_script(self, script): yield while True: if self.lowest_fps <= 25: break yield script.sleep(1) yield script.sleep((1 / self.lightning_density) * random.random()) self.lightning_strike() def lightning(self, density=0.5): if density < EPSILON: self.lightning_slot = None return if self.lowest_fps <= 25: return self.lightning_density = density self.lightning_slot = self.scripts.connect(self.lightning_script) def add_rock(self): if self.lowest_fps <= 25: return velz = self.player.velocity.z if self.player else vec3(0) x = randint(-500, 500) y = GROUND_HEIGHT - 15 z = -4000 ppos = self.player.position if self.player else vec3(0) pos = vec3(ppos.x, 0, ppos.z) + vec3(x, y, z) self.add(Rock(self.app, self, pos, velz)) def add_rain_drop(self): velz = self.player.velocity.z if self.player else 0 x = randint(-400, 400) y = randint(0, 300) z = randint(-3000, -2000) ppos = self.player.position if self.player else vec3(0) pos = vec3(ppos.x, 0, ppos.z) + vec3(x, y, z) self.add(Rain(self.app, self, pos, velz, particle=True)) def rain(self, density=25): if density: self.rain_slot = self.when.every(1 / density, self.add_rain_drop) else: self.rain_slot = None def rocks(self, density=10): if density: self.rock_slot = self.when.every(1 / density, self.add_rock) else: self.rock_slot = None def stars(self): if hasattr(self.app.state, "player"): velz = self.player.velocity.z else: velz = 0 for i in range(50): x = randint(-500, 500) y = -200 + (random.random()**0.5 * 800) z = -3000 pos = vec3(x, y, z) self.add(Star(self.app, self, pos, velz)) def draw_sky(self): width, height = self.app.size / 8 self.sky = pygame.Surface((width, height)) sky_color = self.sky_color or ncolor(pygame.Color("blue")) sky_color = [255 * s for s in sky_color] # for y in range(height): # interp = (1 - y / height) * 2 # for x in range(width): # rand = rand_RGB() # color = rgb_mix(sky_color, rand, 0.02) # c = (sk * 0.98 + rand * 0.02) / i**1.1 # if interp == 0: # color = (255, 255, 255) # else: # color = [min(int(c / interp ** 1.1), 255) for c in color] # self.sky.set_at((x, y), color) # Draw gradient for y in range(height): interp = (1 - y / height) * 2 base = [min(int(c / interp**1.1), 255) for c in sky_color] pygame.draw.line(self.sky, base, (0, y), (width, y)) noise = noise_surf_dense_bottom(self.sky.get_size(), random.randrange(5)) self.sky.blit( noise, (0, 0), None, ) self.sky = pygame.transform.scale(self.sky, self.app.size) # if self.stars_visible: # self.draw_stars(self.sky, self.star_pos) self.sky = pygame.transform.scale(self.sky, self.app.size) # def draw_stars(self, surface, star_positions): # size = 1 # for pos in star_positions: # star = pygame.Surface((size, size)) # star.fill((255, 255, 255)) # star.set_alpha(175) # surface.blit(star, pos) def remove_sound(self, filename): if filename in self.sounds: self.sounds[filename][0].stop() del self.sounds[filename] return True return False def ensure_sound(self, filename, callback=None, *args): """ Ensure a sound is playing. If it isn't, play it. """ if filename in self.sounds: return None, None, None return self.play_sound(filename, callback, *args) def play_sound(self, filename, callback=None, *args): """ Plays the sound with the given filename (relative to SOUNDS_DIR). Returns sound, channel, and callback slot. """ if filename in self.sounds: self.sounds[filename][0].stop() del self.sounds[filename] filename = path.join(SOUNDS_DIR, filename) sound = self.app.load(filename, lambda: pygame.mixer.Sound(filename)) if not sound: return None, None, None channel = pygame.mixer.find_channel() if not channel: return None, None, None channel.set_volume(SOUND_VOLUME) if callback: self.when.once(self.sounds[0].get_length(), callback, weak=False) else: slot = None self.sounds[filename] = (sound, channel, slot) channel.play(sound, *args) self.when.once(sound.get_length(), lambda: self.remove_sound(sound), weak=False) return sound, channel, slot @property def script(self): return self._script @script.setter def script(self, fn): self._script = (Script( self.app, self, fn, use_input=True, script_args=(self.app, self)) if fn else None) @property def music(self): return self._music @property def ground_color(self): return self._ground_color @ground_color.setter def ground_color(self, color): if color is None: return self._ground_color = ncolor(color) if not self.ground: self.ground = self.add(Ground(self.app, self, GROUND_HEIGHT)) self.ground.color = color if "ROCK" in self.app.cache: del self.app.cache["ROCK"] # rocks need to reload their color @music.setter def music(self, filename): self._music = filename if self._music: pygame.mixer.music.load(path.join(MUSIC_DIR, filename)) pygame.mixer.music.play(-1) def on_collision_connect(self, A, B, func, once=True): """ during collision (touching) """ pass def on_collision_once(self, A, B, func, once=True): """ trigger only once """ pass def on_collision_enter(self, A, B, func, once=True): """ trigger upon enter collision """ pass def on_collision_leave(self, A, B, func, once=True): """ trigger upon leave collision """ pass # @property # def script(self): # return self.script_fn # @script.setter # def script(self, script): # print("Script:",script) # if isinstance(script, str): # local = {} # exec(open(path.join(SCRIPTS_DIR, script + ".py")).read(), globals(), local) # self._script = local["script"](self.app, self) # elif isinstance(script, type): # # So we can pass a Level class # self._script = iter(script(self.app, self)) # elif script is None: # self._script = None # else: # raise TypeError # def sleep(self, t): # return self.when.once(t, self.resume) def add(self, entity): slot = self.connect(entity, weak=False) entity.slot = weakref.ref(slot) # self.slotlist += slot return entity @property def sky_color(self): return self._sky_color @sky_color.setter def sky_color(self, 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 = 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) # def resume(self): # self.script_paused = False def invalid_size(self, size): """Checks component for 0 or NaNs""" return any(c != c or abs(c) < EPSILON for c in size) def update_collisions(self, dt): # cause all scene operations to be queueed self.blocked += 1 for slot in self.slots: a = slot.get() # only check if a is solid if not a or not a.solid: continue if self.invalid_size(a.collision_size): continue # for each slot, loop through each slot for slot2 in self.slots: b = slot2.get() # only check if b is solid if not b or not b.solid: continue if slot is not slot2: if not a.has_collision and not b.has_collision: continue if self.invalid_size(b.collision_size): continue a_min = a.position - a.collision_size / 2 a_max = a.position + a.collision_size / 2 b_min = b.position - b.collision_size / 2 b_max = b.position + b.collision_size / 2 col = not (b_min.x > a_max.x or b_max.x < a_min.x or b_min.y > a_max.y or b_max.y < a_min.y or b_min.z > a_max.z or b_max.z < a_min.z) if col: if a.has_collision: a.collision(b, dt) if b.has_collision: b.collision(a, dt) self.blocked -= 1 # run pending slot queue if self.blocked == 0: self.refresh() def stabilize(self): """" Stablize FPS by setting max partilces Called every second (see when.once(1, self.stabilize in init) """ if self.app.fps > 1: self.lowest_fps = min(self.app.fps, self.lowest_fps) if self.app.fps < 45: self.max_particles = max(self.max_particles / 2, 4) if self.lowest_fps >= 60: if self.app.fps > 120: self.max_particles = min(self.max_particles * 2, 64) def filter_script(self, slot): if isinstance(slot, weakref.ref): wref = slot slot = wref() if not slot: return False if slot.get().done(): return False return True def update(self, dt): self.time += dt # print(self.time) self.delay_t = max(0, self.delay_t - dt) # do time-based events self.when.update(dt) 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.sort(key=z_compare) self.slots = list(filter(lambda x: not x.get().removed, self.slots)) # call update(dt) on each entity self.each(lambda x, dt: x.update(dt), dt) # update particles (remove if too many) self.blocked += 1 particle_count = 0 for i, slot in enumerate(reversed(self.slots)): e = slot.get() if e.particle: particle_count += 1 if particle_count >= self.max_particles: slot.disconnect() self.blocked -= 1 self.clean() def render(self, camera): # call render(camera) on all scene entities 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)