Dual Stick Shooter

This example shows how to use both joysticks on a gamepad. As gamepad layout varies a lot, you may need to adjust the mapping with SHOOTING_AXIS_SELECTION.

Screenshot of dual-stick shooter example
dual_stick_shooter.py
  1"""
  2Dual-stick Shooter Example
  3
  4A dual-analog stick joystick is the preferred method of input. If a joystick is
  5not present, the game will fail back to use keyboard controls (WASD to move, arrows to shoot)
  6
  7If Python and Arcade are installed, this example can be run from the command line with:
  8python -m arcade.examples.dual_stick_shooter
  9"""
 10import arcade
 11import random
 12import time
 13import math
 14import os
 15from typing import cast
 16import pprint
 17
 18import pyglet.input.base
 19
 20
 21SCREEN_WIDTH = 1024
 22SCREEN_HEIGHT = 768
 23SCREEN_TITLE = "Dual-stick Shooter Example"
 24MOVEMENT_SPEED = 4
 25BULLET_SPEED = 10
 26BULLET_COOLDOWN_TICKS = 10
 27ENEMY_SPAWN_INTERVAL = 1
 28ENEMY_SPEED = 1
 29JOY_DEADZONE = 0.2
 30# An angle of "0" means "right", but the player's texture is oriented in the "up" direction.
 31# So an offset is needed.
 32ROTATE_OFFSET = -90
 33
 34
 35def dump_obj(obj):
 36    for key in sorted(vars(obj)):
 37        val = getattr(obj, key)
 38        print("{:30} = {} ({})".format(key, val, type(val).__name__))
 39
 40
 41def dump_joystick(joy):
 42    print("========== {}".format(joy))
 43    print("x       {}".format(joy.x))
 44    print("y       {}".format(joy.y))
 45    print("z       {}".format(joy.z))
 46    print("rx      {}".format(joy.rx))
 47    print("ry      {}".format(joy.ry))
 48    print("rz      {}".format(joy.rz))
 49    print("hat_x   {}".format(joy.hat_x))
 50    print("hat_y   {}".format(joy.hat_y))
 51    print("buttons {}".format(joy.buttons))
 52    print("========== Extra joy")
 53    dump_obj(joy)
 54    print("========== Extra joy.device")
 55    dump_obj(joy.device)
 56    print("========== pprint joy")
 57    pprint.pprint(joy)
 58    print("========== pprint joy.device")
 59    pprint.pprint(joy.device)
 60
 61
 62def dump_joystick_state(ticks, joy):
 63    # print("{:5.2f} {:5.2f} {:>20} {:5}_".format(1.234567, -8.2757272903, "hello", str(True)))
 64    fmt_str = "{:6d} "
 65    num_fmts = ["{:5.2f}"] * 6
 66    fmt_str += " ".join(num_fmts)
 67    fmt_str += " {:2d} {:2d} {}"
 68    buttons = " ".join(["{:5}".format(str(b)) for b in joy.buttons])
 69    print(fmt_str.format(ticks,
 70                         joy.x,
 71                         joy.y,
 72                         joy.z,
 73                         joy.rx,
 74                         joy.ry,
 75                         joy.rz,
 76                         joy.hat_x,
 77                         joy.hat_y,
 78                         buttons))
 79
 80
 81def get_joy_position(x, y):
 82    """Given position of joystick axes, return (x, y, angle_in_degrees).
 83    If movement is not outside of deadzone, return (None, None, None)"""
 84    if x > JOY_DEADZONE or x < -JOY_DEADZONE or y > JOY_DEADZONE or y < -JOY_DEADZONE:
 85        y = -y
 86        rad = math.atan2(y, x)
 87        angle = math.degrees(rad)
 88        return x, y, angle
 89    return None, None, None
 90
 91
 92class Player(arcade.sprite.Sprite):
 93    def __init__(self, filename):
 94        super().__init__(filename=filename, scale=0.4, center_x=SCREEN_WIDTH/2, center_y=SCREEN_HEIGHT/2)
 95        self.shoot_up_pressed = False
 96        self.shoot_down_pressed = False
 97        self.shoot_left_pressed = False
 98        self.shoot_right_pressed = False
 99
100
101class Enemy(arcade.sprite.Sprite):
102    def __init__(self, x, y):
103        super().__init__(filename=':resources:images/pinball/bumper.png', scale=0.5, center_x=x, center_y=y)
104
105    def follow_sprite(self, player_sprite):
106        """
107        This function will move the current sprite towards whatever
108        other sprite is specified as a parameter.
109
110        We use the 'min' function here to get the sprite to line up with
111        the target sprite, and not jump around if the sprite is not off
112        an exact multiple of ENEMY_SPEED.
113        """
114
115        if self.center_y < player_sprite.center_y:
116            self.center_y += min(ENEMY_SPEED, player_sprite.center_y - self.center_y)
117        elif self.center_y > player_sprite.center_y:
118            self.center_y -= min(ENEMY_SPEED, self.center_y - player_sprite.center_y)
119
120        if self.center_x < player_sprite.center_x:
121            self.center_x += min(ENEMY_SPEED, player_sprite.center_x - self.center_x)
122        elif self.center_x > player_sprite.center_x:
123            self.center_x -= min(ENEMY_SPEED, self.center_x - player_sprite.center_x)
124
125
126class MyGame(arcade.View):
127    def __init__(self):
128        super().__init__()
129        self.game_over = False
130        self.score = 0
131        self.tick = 0
132        self.bullet_cooldown = 0
133        self.player = Player(":resources:images/space_shooter/playerShip2_orange.png")
134        self.bullet_list = arcade.SpriteList()
135        self.enemy_list = arcade.SpriteList()
136        self.joy = None
137
138    def on_show(self):
139        arcade.set_background_color(arcade.color.DARK_MIDNIGHT_BLUE)
140        joys = self.window.joys
141        for joy in joys:
142            dump_joystick(joy)
143        if joys:
144            self.joy = joys[0]
145            print("Using joystick controls: {}".format(self.joy.device))
146            arcade.window_commands.schedule(self.debug_joy_state, 0.1)
147        if not self.joy:
148            print("No joystick present, using keyboard controls")
149        arcade.window_commands.schedule(self.spawn_enemy, ENEMY_SPAWN_INTERVAL)
150
151    def debug_joy_state(self, _delta_time):
152        dump_joystick_state(self.tick, self.joy)
153
154    def spawn_enemy(self, _elapsed):
155        if self.game_over:
156            return
157        x = random.randint(0, SCREEN_WIDTH)
158        y = random.randint(0, SCREEN_HEIGHT)
159        self.enemy_list.append(Enemy(x, y))
160
161    def on_update(self, delta_time):
162        self.tick += 1
163        if self.game_over:
164            return
165
166        self.bullet_cooldown += 1
167
168        for enemy in self.enemy_list:
169            cast(Enemy, enemy).follow_sprite(self.player)
170
171        if self.joy:
172            # Joystick input - movement
173            move_x, move_y, move_angle = get_joy_position(self.joy.move_stick_x, self.joy.move_stick_y)
174            if move_angle:
175                self.player.change_x = move_x * MOVEMENT_SPEED
176                self.player.change_y = move_y * MOVEMENT_SPEED
177                self.player.angle = move_angle + ROTATE_OFFSET
178            else:
179                self.player.change_x = 0
180                self.player.change_y = 0
181
182            # Joystick input - shooting
183            shoot_x, shoot_y, shoot_angle = get_joy_position(self.joy.shoot_stick_x, self.joy.shoot_stick_y)
184            if shoot_angle:
185                self.spawn_bullet(shoot_angle)
186        else:
187            # Keyboard input - shooting
188            if self.player.shoot_right_pressed and self.player.shoot_up_pressed:
189                self.spawn_bullet(0+45)
190            elif self.player.shoot_up_pressed and self.player.shoot_left_pressed:
191                self.spawn_bullet(90+45)
192            elif self.player.shoot_left_pressed and self.player.shoot_down_pressed:
193                self.spawn_bullet(180+45)
194            elif self.player.shoot_down_pressed and self.player.shoot_right_pressed:
195                self.spawn_bullet(270+45)
196            elif self.player.shoot_right_pressed:
197                self.spawn_bullet(0)
198            elif self.player.shoot_up_pressed:
199                self.spawn_bullet(90)
200            elif self.player.shoot_left_pressed:
201                self.spawn_bullet(180)
202            elif self.player.shoot_down_pressed:
203                self.spawn_bullet(270)
204
205        self.enemy_list.update()
206        self.player.update()
207        self.bullet_list.update()
208        ship_death_hit_list = arcade.check_for_collision_with_list(self.player, self.enemy_list)
209        if len(ship_death_hit_list) > 0:
210            self.game_over = True
211        for bullet in self.bullet_list:
212            bullet_killed = False
213            enemy_shot_list = arcade.check_for_collision_with_list(bullet, self.enemy_list)
214            # Loop through each colliding sprite, remove it, and add to the score.
215            for enemy in enemy_shot_list:
216                enemy.remove_from_sprite_lists()
217                bullet.remove_from_sprite_lists()
218                bullet_killed = True
219                self.score += 1
220            if bullet_killed:
221                continue
222
223    def on_key_press(self, key, modifiers):
224        if key == arcade.key.W:
225            self.player.change_y = MOVEMENT_SPEED
226        elif key == arcade.key.A:
227            self.player.change_x = -MOVEMENT_SPEED
228        elif key == arcade.key.S:
229            self.player.change_y = -MOVEMENT_SPEED
230        elif key == arcade.key.D:
231            self.player.change_x = MOVEMENT_SPEED
232        elif key == arcade.key.RIGHT:
233            self.player.shoot_right_pressed = True
234        elif key == arcade.key.UP:
235            self.player.shoot_up_pressed = True
236        elif key == arcade.key.LEFT:
237            self.player.shoot_left_pressed = True
238        elif key == arcade.key.DOWN:
239            self.player.shoot_down_pressed = True
240
241        rad = math.atan2(self.player.change_y, self.player.change_x)
242        self.player.angle = math.degrees(rad) + ROTATE_OFFSET
243
244    def on_key_release(self, key, modifiers):
245        if key == arcade.key.W:
246            self.player.change_y = 0
247        elif key == arcade.key.A:
248            self.player.change_x = 0
249        elif key == arcade.key.S:
250            self.player.change_y = 0
251        elif key == arcade.key.D:
252            self.player.change_x = 0
253        elif key == arcade.key.RIGHT:
254            self.player.shoot_right_pressed = False
255        elif key == arcade.key.UP:
256            self.player.shoot_up_pressed = False
257        elif key == arcade.key.LEFT:
258            self.player.shoot_left_pressed = False
259        elif key == arcade.key.DOWN:
260            self.player.shoot_down_pressed = False
261
262        rad = math.atan2(self.player.change_y, self.player.change_x)
263        self.player.angle = math.degrees(rad) + ROTATE_OFFSET
264
265    def spawn_bullet(self, angle_in_deg):
266        # only allow bullet to spawn on an interval
267        if self.bullet_cooldown < BULLET_COOLDOWN_TICKS:
268            return
269        self.bullet_cooldown = 0
270
271        bullet = arcade.Sprite(":resources:images/space_shooter/laserBlue01.png", 0.75)
272
273        # Position the bullet at the player's current location
274        start_x = self.player.center_x
275        start_y = self.player.center_y
276        bullet.center_x = start_x
277        bullet.center_y = start_y
278
279        # angle the bullet visually
280        bullet.angle = angle_in_deg
281        angle_in_rad = math.radians(angle_in_deg)
282
283        # set bullet's movement direction
284        bullet.change_x = math.cos(angle_in_rad) * BULLET_SPEED
285        bullet.change_y = math.sin(angle_in_rad) * BULLET_SPEED
286
287        # Add the bullet to the appropriate lists
288        self.bullet_list.append(bullet)
289
290    def on_draw(self):
291        # clear screen and start render process
292        arcade.start_render()
293
294        # draw game items
295        self.bullet_list.draw()
296        self.enemy_list.draw()
297        self.player.draw()
298
299        # Put the score on the screen.
300        output = f"Score: {self.score}"
301        arcade.draw_text(output, 10, 20, arcade.color.WHITE, 14)
302
303        # Game over message
304        if self.game_over:
305            arcade.draw_text("Game Over", SCREEN_WIDTH/2, SCREEN_HEIGHT/2, arcade.color.WHITE, 100, width=SCREEN_WIDTH,
306                             align="center", anchor_x="center", anchor_y="center")
307
308
309class JoyConfigView(arcade.View):
310    """A View that allows a user to interactively configure their joystick"""
311    REGISTRATION_PAUSE = 1.5
312    NO_JOYSTICK_PAUSE = 2.0
313    JOY_ATTRS = ("x", "y", "z", "rx", "ry", "rz")
314
315    def __init__(self, joy_method_names, joysticks, next_view, width, height):
316        super().__init__()
317        self.next_view = next_view
318        self.width = width
319        self.height = height
320        self.msg = ""
321        self.script = self.joy_config_script()
322        self.joys = joysticks
323        arcade.set_background_color(arcade.color.WHITE)
324        if len(joysticks) > 0:
325            self.joy = joysticks[0]
326            self.joy_method_names = joy_method_names
327            self.axis_ranges = {}
328
329    def config_axis(self, joy_axis_label, method_name):
330        self.msg = joy_axis_label
331        self.axis_ranges = {a: 0.0 for a in self.JOY_ATTRS}
332        while max([v for k, v in self.axis_ranges.items()]) < 0.85:
333            for attr, farthest_val in self.axis_ranges.items():
334                cur_val = getattr(self.joy, attr)
335                if abs(cur_val) > abs(farthest_val):
336                    self.axis_ranges[attr] = abs(cur_val)
337            yield
338
339        max_val = 0.0
340        max_attr = None
341        for attr, farthest_val in self.axis_ranges.items():
342            if farthest_val > max_val:
343                max_attr = attr
344                max_val = farthest_val
345        self.msg = f"Registered!"
346
347        setattr(pyglet.input.base.Joystick, method_name, property(lambda that: getattr(that, max_attr), None))
348
349        # pause briefly after registering an axis
350        yield from self._pause(self.REGISTRATION_PAUSE)
351
352    def joy_config_script(self):
353        if len(self.joys) == 0:
354            self.msg = "No joysticks found!  Use keyboard controls."
355            yield from self._pause(self.NO_JOYSTICK_PAUSE)
356            return
357
358        for joy_axis_label, method_name in self.joy_method_names:
359            yield from self.config_axis(joy_axis_label, method_name)
360
361    def on_update(self, delta_time):
362        try:
363            next(self.script)
364        except StopIteration:
365            self.window.show_view(self.next_view)
366
367    def on_draw(self):
368        arcade.start_render()
369        arcade.draw_text("Configure your joystick", self.width/2, self.height/2+100,
370                         arcade.color.BLACK, font_size=32, anchor_x="center")
371        arcade.draw_text(self.msg, self.width/2, self.height/2,
372                         arcade.color.BLACK, font_size=24, anchor_x="center")
373
374    def _pause(self, delay):
375        """Block a generator from advancing for the given delay. Call with 'yield from self._pause(1.0)"""
376        start = time.time()
377        end = start + delay
378        while time.time() < end:
379            yield
380
381
382if __name__ == "__main__":
383    window = arcade.Window(SCREEN_WIDTH, SCREEN_HEIGHT, SCREEN_TITLE)
384
385    # Set the working directory (where we expect to find files) to the same
386    # directory this .py file is in. You can leave this out of your own
387    # code, but it is needed to easily run the examples using "python -m"
388    # as mentioned at the top of this program.
389    file_path = os.path.dirname(os.path.abspath(__file__))
390    os.chdir(file_path)
391
392    window.joys = arcade.get_joysticks()
393    for j in window.joys:
394        j.open()
395    joy_config_method_names = (
396        ("Move the movement stick left or right", "move_stick_x"),
397        ("Move the movement stick up or down", "move_stick_y"),
398        ("Move the shooting stick left or right", "shoot_stick_x"),
399        ("Move the shooting stick up or down", "shoot_stick_y"),
400    )
401    game = MyGame()
402    window.show_view(JoyConfigView(joy_config_method_names, window.joys, game, SCREEN_WIDTH, SCREEN_HEIGHT))
403    arcade.run()