view pytouhou/game/enemy.pyx @ 792:11bc22bad1bf

python: Replace the image crate with png We weren’t using any of its features anyway, so the png crate is exactly what we need, without the many heavy dependencies of image. https://github.com/image-rs/image-png/pull/670 will eventually make it even faster to build.
author Link Mauve <linkmauve@linkmauve.fr>
date Sat, 17 Jan 2026 22:22:25 +0100
parents a6af3ff86612
children
line wrap: on
line source

# -*- encoding: utf-8 -*-
##
## Copyright (C) 2011 Thibaut Girka <thib@sitedethib.com>
##
## This program is free software; you can redistribute it and/or modify
## it under the terms of the GNU General Public License as published
## by the Free Software Foundation; version 3 only.
##
## This program is distributed in the hope that it will be useful,
## but WITHOUT ANY WARRANTY; without even the implied warranty of
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
## GNU General Public License for more details.
##

from libc.math cimport cos, sin, atan2, M_PI as pi

from pytouhou.vm import ANMRunner
from pytouhou.game.sprite import Sprite
from pytouhou.game.bullet cimport Bullet, LAUNCHED
from pytouhou.game.laser cimport Laser, PlayerLaser
from pytouhou.game.effect cimport Effect


cdef class Callback:
    def __init__(self, function=None, args=()):
        self.function = function
        self.args = args

    def __nonzero__(self):
        return self.function is not None

    cpdef enable(self, function, tuple args):
        self.function = function
        self.args = args

    cpdef disable(self):
        self.function = None

    cpdef fire(self):
        if self.function is not None:
            self.function(*self.args)
            self.function = None


cdef class Enemy(Element):
    def __init__(self, pos, long life, long _type, long bonus_dropped, long die_score, anms, Game game):
        Element.__init__(self)

        self._game = game
        self._anms = anms
        self._type = _type

        self.process = None
        self.visible = True
        self.was_visible = False
        self.bonus_dropped = bonus_dropped
        self.die_score = die_score

        self.frame = 0

        self.x, self.y, self.z = pos
        self.life = 1 if life < 0 else life
        self.touchable = True
        self.collidable = True
        self.damageable = True
        self.death_flags = 0
        self.boss = False
        self.difficulty_coeffs = (-.5, .5, 0, 0, 0, 0)
        self.extended_bullet_attributes = (0, 0, 0, 0, 0., 0., 0., 0.)
        self.current_laser_id = 0
        self.laser_by_id = {}
        self.bullet_attributes = None
        self.bullet_launch_offset = (0, 0)
        self.low_life_trigger = -1
        self.timeout = -1
        self.remaining_lives = 0

        self.death_callback = Callback()
        self.boss_callback = Callback()
        self.low_life_callback = Callback()
        self.timeout_callback = Callback()

        self.automatic_orientation = False

        self.bullet_launch_interval = 0
        self.bullet_launch_timer = 0
        self.delay_attack = False

        self.death_anim = 0
        self.movement_dependant_sprites = None
        self.direction = 0
        self.interpolator = None #TODO
        self.speed_interpolator = None
        self.update_mode = 0
        self.angle = 0.
        self.speed = 0.
        self.rotation_speed = 0.
        self.acceleration = 0.

        self.hitbox_half_size[:] = [0, 0]
        self.screen_box = None

        self.aux_anm = 8 * [None]


    property objects:
        def __get__(self):
            return [self] + [anm for anm in self.aux_anm if anm is not None]


    cpdef play_sound(self, index):
        name = {
            5: 'power0',
            6: 'power1',
            7: 'tan00',
            8: 'tan01',
            9: 'tan02',
            14: 'cat00',
            16: 'lazer00',
            17: 'lazer01',
            18: 'enep01',
            22: 'tan00', #XXX
            24: 'tan02', #XXX
            25: 'kira00',
            26: 'kira01',
            27: 'kira02'
        }[index]
        self._game.sfx_player.play('%s.wav' % name)


    cpdef set_hitbox(self, double width, double height):
        self.hitbox_half_size[:] = [width / 2, height / 2]


    cpdef set_bullet_attributes(self, type_, anim, sprite_idx_offset,
                                unsigned long bullets_per_shot,
                                unsigned long number_of_shots, double speed,
                                double speed2, launch_angle, angle, flags):
        cdef double speed_a, speed_b
        cdef long nb_a, nb_b, shots_a, shots_b

        # Apply difficulty-specific modifiers
        speed_a, speed_b, nb_a, nb_b, shots_a, shots_b = self.difficulty_coeffs
        diff_coeff = self._game.difficulty / 32.

        speed += speed_a * (1. - diff_coeff) + speed_b * diff_coeff
        speed2 += (speed_a * (1. - diff_coeff) + speed_b * diff_coeff) / 2.
        bullets_per_shot += <long>(nb_a * (1. - diff_coeff) + nb_b * diff_coeff)
        number_of_shots += <long>(shots_a * (1. - diff_coeff) + shots_b * diff_coeff)

        self.bullet_attributes = (type_, anim, sprite_idx_offset, bullets_per_shot,
                                  number_of_shots, speed, speed2, launch_angle,
                                  angle, flags)
        if not self.delay_attack:
            self.fire()


    cpdef set_bullet_launch_interval(self, long value, unsigned long start=0):
        # Apply difficulty-specific modifiers:
        #TODO: check every value possible! Look around 102h.exe@0x408720
        value -= value * (self._game.difficulty - 16) // 80

        self.bullet_launch_interval = value
        self.bullet_launch_timer = start % value if value > 0 else 0


    cpdef fire(self, offset=None, bullet_attributes=None, tuple launch_pos=None):
        cdef unsigned long type_, bullets_per_shot, number_of_shots
        cdef double speed, speed2, launch_angle, angle

        (type_, type_idx, sprite_idx_offset, bullets_per_shot, number_of_shots,
         speed, speed2, launch_angle, angle, flags) = bullet_attributes or self.bullet_attributes

        bullet_type = self._game.bullet_types[type_idx]

        if launch_pos is None:
            ox, oy = offset or self.bullet_launch_offset
            launch_pos = self.x + ox, self.y + oy

        if speed < 0.3 and speed != 0.0:
            speed = 0.3
        if speed2 < 0.3:
            speed2 = 0.3

        self.bullet_launch_timer = 0

        player = self.select_player()

        if type_ in (67, 69, 71):
            launch_angle += self.get_angle(player, launch_pos)
        if type_ == 71 and bullets_per_shot % 2 or type_ in (69, 70) and not bullets_per_shot % 2:
            launch_angle += pi / bullets_per_shot
        if type_ != 75:
            launch_angle -= angle * (bullets_per_shot - 1) / 2.

        bullets = self._game.bullets
        nb_bullets_max = self._game.nb_bullets_max

        for shot_nb in range(number_of_shots):
            shot_speed = speed if shot_nb == 0 else speed + (speed2 - speed) * float(shot_nb) / float(number_of_shots)
            bullet_angle = launch_angle
            if type_ in (69, 70, 71, 74):
                launch_angle += angle
            for bullet_nb in range(bullets_per_shot):
                if nb_bullets_max is not None and len(bullets) == nb_bullets_max:
                    break

                if type_ == 75: # 102h.exe@0x4138cf
                    bullet_angle = self._game.prng.rand_double() * (launch_angle - angle) + angle
                if type_ in (74, 75): # 102h.exe@0x4138cf
                    shot_speed = self._game.prng.rand_double() * (speed - speed2) + speed2
                bullets.append(Bullet(launch_pos, bullet_type, sprite_idx_offset,
                                      bullet_angle, shot_speed,
                                      self.extended_bullet_attributes,
                                      flags, player, self._game))

                if type_ in (69, 70, 71, 74):
                    bullet_angle += 2. * pi / bullets_per_shot
                else:
                    bullet_angle += angle


    cpdef new_laser(self, unsigned long variant, laser_type, sprite_idx_offset,
                    double angle, speed, start_offset, end_offset, max_length,
                    width, start_duration, duration, end_duration,
                    grazing_delay, grazing_extra_duration, unknown,
                    tuple offset=None):
        cdef double ox, oy

        if offset is None:
            offset = self.bullet_launch_offset
        ox, oy = offset
        launch_pos = self.x + ox, self.y + oy
        if variant == 86:
            player = self.select_player()
            angle += self.get_angle(player, launch_pos)
        laser = Laser(launch_pos, self._game.laser_types[laser_type],
                      sprite_idx_offset, angle, speed,
                      start_offset, end_offset, max_length, width,
                      start_duration, duration, end_duration, grazing_delay,
                      grazing_extra_duration, self._game)
        self._game.lasers.append(laser)
        self.laser_by_id[self.current_laser_id] = laser


    cpdef Player select_player(self, list players=None):
        if players is None:
            players = self._game.players
        return min(players, key=self.select_player_key)


    cpdef double get_angle(self, Element target, tuple pos=None) except 42:
        cdef double x, y
        x, y = pos or (self.x, self.y)
        return atan2(target.y - y, target.x - x)


    cpdef set_anim(self, index):
        entry = 0 if index in self._anms[0].scripts else 1
        self.sprite = Sprite()
        self.anmrunner = ANMRunner(self._anms[entry], index, self.sprite)


    cdef bint die_anim(self) except True:
        anim = {0: 3, 1: 4, 2: 5}[self.death_anim % 256] # The TB is wanted, if index isn’t in these values the original game crashs.
        self._game.new_effect((self.x, self.y), anim)
        self._game.sfx_player.play('enep00.wav')


    cdef bint drop_particles(self, long number, long color) except True:
        if color == 0:
            if self._game.stage in [1, 2, 7]:
                color = 3
        color += 9
        for i in range(number):
            self._game.new_particle((self.x, self.y), color, 256) #TODO: find the real size.


    cpdef set_aux_anm(self, long number, long index):
        entry = 0 if index in self._anms[0].scripts else 1
        self.aux_anm[number] = Effect((self.x, self.y), index, self._anms[entry])


    cpdef set_pos(self, double x, double y, double z):
        self.x, self.y = x, y
        self.update_mode = 1
        self.interpolator = Interpolator((x, y), self._game.frame)


    cpdef move_to(self, unsigned long duration, double x, double y, double z,
                  formula):
        frame = self._game.frame
        self.speed_interpolator = None
        self.update_mode = 1
        self.interpolator = Interpolator((self.x, self.y), frame,
                                         (x, y), frame + duration - 1,
                                         formula)

        self.angle = atan2(y - self.y, x - self.x)


    cpdef stop_in(self, unsigned long duration, formula):
        frame = self._game.frame
        self.interpolator = None
        self.update_mode = 1
        self.speed_interpolator = Interpolator((self.speed,), frame,
                                               (0.,), frame + duration - 1,
                                               formula)


    cpdef set_boss(self, bint enable):
        if enable:
            self.boss = True
            self._game.boss = self
            self._game.interface.set_boss_life()
        else:
            self.boss = False
            self._game.boss = None


    cdef bint is_visible(self, long screen_width, long screen_height) except -1:
        if self.sprite is not None:
            if self.sprite.corner_relative_placement:
                raise Exception #TODO
            tw, th = self.sprite._texcoords[2], self.sprite._texcoords[3]
        else:
            tw, th = 0., 0.

        x, y = self.x, self.y
        max_x = tw / 2
        max_y = th / 2

        if (max_x < x - screen_width
            or max_x < -x
            or max_y < y - screen_height
            or max_y < -y):
            return False
        return True


    cdef bint check_collisions(self) except True:
        cdef Bullet bullet
        cdef Player player
        cdef PlayerLaser laser
        cdef long damages
        cdef double half_size[2]
        cdef double phalf_size

        # Check for collisions
        ex, ey = self.x, self.y
        ehalf_size_x = self.hitbox_half_size[0]
        ehalf_size_y = self.hitbox_half_size[1]
        ex1, ex2 = ex - ehalf_size_x, ex + ehalf_size_x
        ey1, ey2 = ey - ehalf_size_y, ey + ehalf_size_y

        damages = 0

        # Check for enemy-bullet collisions
        for bullet in self._game.players_bullets:
            if bullet.state != LAUNCHED:
                continue
            half_size[0] = bullet.hitbox[0]
            half_size[1] = bullet.hitbox[1]
            bx, by = bullet.x, bullet.y
            bx1, bx2 = bx - half_size[0], bx + half_size[0]
            by1, by2 = by - half_size[1], by + half_size[1]

            if not (bx2 < ex1 or bx1 > ex2
                    or by2 < ey1 or by1 > ey2):
                bullet.collide()
                damages += bullet.damage
                self._game.sfx_player.play('damage00.wav')

        # Check for enemy-laser collisions
        for laser in self._game.players_lasers:
            if not laser:
                continue

            half_size[0] = laser.hitbox[0]
            lx, ly = laser.x, laser.y * 2.
            lx1, lx2 = lx - half_size[0], lx + half_size[0]

            if not (lx2 < ex1 or lx1 > ex2
                    or ly < ey1):
                damages += laser.damage
                self._game.sfx_player.play('damage00.wav')
                self.drop_particles(1, 1) #TODO: don’t call each frame.

        # Check for enemy-player collisions
        ex1, ex2 = ex - ehalf_size_x * 2. / 3., ex + ehalf_size_x * 2. / 3.
        ey1, ey2 = ey - ehalf_size_y * 2. / 3., ey + ehalf_size_y * 2. / 3.
        if self.collidable:
            for player in self._game.players:
                px, py = player.x, player.y
                phalf_size = player.sht.hitbox
                px1, px2 = px - phalf_size, px + phalf_size
                py1, py2 = py - phalf_size, py + phalf_size

                #TODO: box-box or point-in-box?
                if not (ex2 < px1 or ex1 > px2 or ey2 < py1 or ey1 > py2):
                    if not self.boss:
                        damages += 10
                    player.collide()

        # Adjust damages
        damages = min(70, damages)
        score = (damages // 5) * 10
        self._game.players[0].score += score #TODO: better distribution amongst the players.

        if self.damageable:
            if self._game.spellcard is not None:
                #TODO: there is a division by 3, somewhere... where is it?
                if damages <= 7:
                    damages = 1 if damages else 0
                else:
                    damages //= 7

            nb_players = len(self._game.players)
            if nb_players > 1:
                if damages <= nb_players:
                    damages = 1 if damages else 0
                else:
                    damages //= nb_players

            # Apply damages
            self.life -= damages


    cdef bint handle_callbacks(self) except True:
        #TODO: implement missing callbacks and clean up!
        if self.life <= 0 and self.touchable:
            self.timeout = -1 #TODO: not really true, the timeout is frozen
            self.timeout_callback.disable()
            death_flags = self.death_flags & 7

            self.die_anim()

            #TODO: verify if the score is added with all the different flags.
            self._game.players[0].score += self.die_score #TODO: better distribution amongst the players.

            #TODO: verify if that should really be there.
            if self.boss:
                self._game.change_bullets_into_bonus()

            if death_flags < 4:
                if self.bonus_dropped > -1:
                    self.drop_particles(7, 0)
                    self._game.drop_bonus(self.x, self.y, self.bonus_dropped)
                elif self.bonus_dropped == -1:
                    if self._game.deaths_count % 3 == 0:
                        self.drop_particles(10, 0)
                        self._game.drop_bonus(self.x, self.y, self._game.bonus_list[self._game.next_bonus])
                        self._game.next_bonus = (self._game.next_bonus + 1) % 32
                    else:
                        self.drop_particles(4, 0)
                    self._game.deaths_count += 1
                else:
                    self.drop_particles(4, 0)

                if death_flags == 0:
                    self.removed = True
                    return False

                if death_flags == 1:
                    if self.boss:
                        self.boss = False #TODO: really?
                        self._game.boss = None
                    self.touchable = False
                elif death_flags == 2:
                    pass # Just that?
                elif death_flags == 3:
                    if self.boss:
                        self.boss = False #TODO: really?
                        self._game.boss = None
                    self.damageable = False
                    self.life = 1
                    self.death_flags = 0

            if death_flags != 0:
                self.death_callback.fire()
        elif self.life <= self.low_life_trigger and self.low_life_callback:
            self.low_life_callback.fire()
            self.low_life_trigger = -1
            self.timeout_callback.disable()
        elif self.timeout != -1 and self.frame == self.timeout:
            self.frame = 0
            self.timeout = -1
            self._game.kill_enemies()
            self._game.cancel_bullets()

            if self.low_life_trigger > 0:
                self.life = self.low_life_trigger
                self.low_life_trigger = -1

            if self.timeout_callback:
                self.timeout_callback.fire()
            #TODO: this is only done under certain (unknown) conditions!
            # but it shouldn't hurt anyway, as the only option left is to crash!
            elif self.death_callback:
                self.life = 0 #TODO: do this next frame? Bypass self.touchable?
            else:
                raise Exception('What the hell, man!')


    cdef bint update(self) except True:
        cdef double x, y, speed

        if self.process is not None:
            self.process.run_iteration()

        x, y = self.x, self.y

        if self.update_mode == 1:
            speed = 0.
            if self.interpolator:
                self.interpolator.update(self._game.frame)
                x, y = self.interpolator.values
            if self.speed_interpolator:
                self.speed_interpolator.update(self._game.frame)
                speed, = self.speed_interpolator.values
        else:
            speed = self.speed
            self.speed += self.acceleration
            self.angle += self.rotation_speed

        dx, dy = cos(self.angle) * speed, sin(self.angle) * speed
        if self._type & 2:
            x -= dx
        else:
            x += dx
        y += dy

        if self.movement_dependant_sprites is not None:
            #TODO: is that really how it works? Almost.
            # Sprite determination is done only once per changement, and is
            # superseeded by ins_97.
            end_left, end_right, left, right = self.movement_dependant_sprites
            if x < self.x and self.direction != -1:
                self.set_anim(left)
                self.direction = -1
            elif x > self.x and self.direction != +1:
                self.set_anim(right)
                self.direction = +1
            elif x == self.x and self.direction != 0:
                self.set_anim({-1: end_left, +1: end_right}[self.direction])
                self.direction = 0


        if self.screen_box is not None:
            xmin, ymin, xmax, ymax = self.screen_box
            x = max(xmin, min(x, xmax))
            y = max(ymin, min(y, ymax))


        self.x, self.y = x, y

        #TODO
        if self.anmrunner is not None and not self.anmrunner.run_frame():
            self.anmrunner = None

        if self.sprite is not None and self.visible:
            if self.sprite.removed:
                self.sprite = None
            else:
                self.sprite.update_orientation(self.angle,
                                               self.automatic_orientation)


        if self.bullet_launch_interval != 0:
            self.bullet_launch_timer += 1
            if self.bullet_launch_timer == self.bullet_launch_interval:
                self.fire()

        # Check collisions
        if self.touchable:
            self.check_collisions()

        for anm in self.aux_anm:
            if anm is not None:
                anm.x, anm.y = self.x, self.y
                anm.update()

        self.handle_callbacks()

        self.frame += 1


    def select_player_key(self, player):
        return ((player.x - self.x) ** 2 + (player.y - self.y) ** 2, player.character)