Tuesday, November 22, 2011

Many mice in a game.

After finishing a PyGame tutorial at Writing a game in Python with Pygame. Part II it reminded me of the Commodore Amiga version of Lemmings which had a two mouse mode, (recently World of Goo also included a multi-mouse mode.)

At this point I started looking at the PyGame mouse API and realised it didn't support multiple independent mice, instead using the motion from all mice to move the one cursor. (It was good fun playing with two mice, one cursor and watching the tug-of-war.)

To support more than one mouse would need the mouse events to include a mouse number. So I resolved to implement a many mouse mode for the Creeps game from the tutorial.

Firstly I needed the mouse input. As I'm using a Linux system I have a series of files in the folder:

/dev/input/mouse0
/dev/input/mouse1

these files have the messages from the mice that are plugged in, (one file per mouse.) Dumping through The message format was three bytes and seemed to conform to PS/2. The events can be printed using:

$ hexdump -C mouse0

My laptop touchpad doesn't appear to be linked to any of the files, when I plug in a USB mouse it will have it's own file with it's events. Some reading on PS/2 later I was ready to implement.

The mice module opens all the input files and sets the handles to non-blocking so that read will not block if there are no bytes waiting.
>>> import os
>>> import fcntl
>>> mouse_files = [(filename[5:],open('/dev/input/%s' % filename))
...     for filename in os.listdir('/dev/input')
...     if filename.startswith('mouse')]
>>> for mouse_no, mouse_file in mouse_files: # Set non-blocking on each file
...     fcntl.fcntl(mouse_file.fileno(), fcntl.F_SETFL, os.O_NONBLOCK)
We also define a Mouse class for each mouse file found which when polled tracks the byte stream from the file and generates the events with an ID associated.
>>> class Mouse(object):
...     def __init__(self, in_file, num, context=None):
...         self.num = num
...         self.f = in_file
...         self.b = ''
...         self.lmb = False
...         self.rmb = False
...         self.mmb = False
...         self.x = 0
...         self.y = 0
...         if context == None:
...             context = [-99999,99999,-99999,99999]
...         self.min_x, self.max_x, self.min_y, self.max_y = context 
The b member is for holding any partial message that has been read, (each message is three bytes.) The context member is for setting an absolute coordinate range for the pointer. I think the other members are fairly clear.
...     def poll(self, notify=None):
...         read_a_message = False
...         expect_n_bytes = 3
...         # Check how many bytes we have buffered
...         if len(self.b):
...             # Reduce our expected bytes by the buffer length
...             expect_n_bytes = expect_n_bytes - len(self.b)
...         try:
...             i = self.f.read(expect_n_bytes)
...         except:
...             i = '' # On error set to zero length string
...         # Was the input the length we were expecting?
...         if len(i) == expect_n_bytes:
...             read_a_message = True
...             if expect_n_bytes != 3:
...                 # Merge any buffered bytes
...                 i = self.b + i
...                 self.b = ''
...             # Dump the message
...             self.dump(i, notify)
...         else:
...             # Buffer partial message
...             self.b = self.b + i
...         return read_a_message 
This helper is used when there is no call back passed, it will print each message.
...     def _notify_print(num, message, values):
...         print "Device %s,",
...         if message == 'delta':
...             print "(%s, %s)" % (values[2], values[3]),
...             print "Device %s, %s down" % (self.num, name.upper())
...         else:
...             print "%s" % values.upper(),
...         print "%s" % message 
The dump method changes the three byte PS/2 message into an event that makes more sense to an application: mouse button down, mouse button up, or mouse move. If notify is supplied then it is assumed to be a callable and invoked with each message. Otherwise the message is printed.
...     def dump(self, i, notify=_notify_print):
...         i = (ord(i[0]), ord(i[1]), ord(i[2]))
...         move_left = i[0] & 0x10 > 0
...         move_down = i[0] & 0x20 > 0
...         lmb = i[0] & 0x1 > 0
...         rmb = i[0] & 0x2 > 0
...         mmb = i[0] & 0x4 > 0
...         x_d = i[1]
...         y_d = i[2]
...         if move_left:
...             x_d = (256 - x_d) * -1
...         if move_down:
...             y_d = (256 - y_d) * -1
...         for button, name in ((lmb,'lmb'),(rmb,'rmb'),(mmb,'mmb')):
...           if button:
...             if not getattr(self, name):
...                 # Button is down in message, not down in member
...                 setattr(self, name, True)
...                 notify(self.num, 'down', name)
...           else:
...             if getattr(self, name):
...                 setattr(self, name, False)
...                 # Button up
...                 notify(self.num, 'up', name)
...         if x_d != 0 or y_d != 0:
...             # Update our absolute position, clamped by context
...             new_x = self.x + x_d
...             new_y = self.y - y_d
...             self.x = new_x
...             if new_x < self.min_x: self.x = self.min_x
...             if new_x > self.max_x: self.x = self.max_x
...             self.y = new_y
...             if new_y < self.min_y: self.y = self.min_y
...             if new_y > self.max_y: self.y = self.max_y 
...             notify(self.num, 'delta', [self.x, self.y, x_d, y_d])
Lastly we create a member in the module to store the Mouse instances for each file.
>>> mice = [Mouse(mouse_file, mouse_no)
...         for mouse_no, mouse_file in mouse_files]
Now to adapt Creeps to support the new mouse protocol, firstly we need to manage the mouse as it is seen by PyGame.
>>> import pygame.mouse
>>> pygame.mouse.set_visible(0)
And in the "while True:" game loop we need to reset the mouse position each frame to stop the (invisible) mouse cursor from escaping the application.
...         time_passed = clock.tick(50) # insert below here
...         pygame.mouse.set_pos([250,200])
At the top below where we set the cursor to invisible we need to import the mice module, create a map of mice by ID and set each mouse to only move within the game area:
>>> import mice
>>> mice_by_id = {}
>>> for mouse in mice.mice:
...     mouse.min_x = 50
...     mouse.max_x = 349
...     mouse.min_y = 50
...     mouse.max_y = 349
...     mice_by_id[mouse.num] = mouse
Without a mouse cursor it is not possible to see where the mouse is, to minimise effort we largely lift the code for the creeps:
>>> creeps = None # insert below here
>>> cursors = None
>>> cursors_list = None
Then to setup the cursors:
>>> # insert below the creeps loop
>>> global cursors 
>>> cursors = pygame.sprite.Group()
>>> global cursors_list
>>> cursors_list = [] 
>>> for i in range(len(mice.mice)):
...     c = Creep(  screen=screen,
...         creep_image=choice(creep_images),  
...         explosion_images=explosion_images, 
...         field=FIELD_RECT, 
...         init_position=(randint(FIELD_RECT.left, FIELD_RECT.right),
...             randint(FIELD_RECT.top, FIELD_RECT.bottom)),  
...         init_direction=(choice([-1, 1]), choice([-1, 1])), 
...         speed=0.00) 
...     cursors.add(c)
...     cursors_list += [c]
We also need to paint the cursors each frame:
...                 creep.draw()  # insert below here
...             for cursor in cursors: 
...                 cursor.update(time_passed) 
...                 cursor.draw()
With no way to click on the close button we need to add a way to quit without it:
...                 paused = not paused  # insert below here
...             if event.key == pygame.K_ESCAPE: 
...                 exit_game()
Below the event loop add a second loop to poll the mice for events, (we will define this function soon):
...         for mouse in mice.mice:
...             mouse.poll(handle_mouse)
Now to tie it all together the mouse handler:
>>> def handle_mouse(device, event_type, argument): 
...     mouse = mice_by_id[device] 
...         if event_type == 'down': 
...             for creep in creeps: 
...                 creep.mouse_click_event([mouse.x, mouse.y]) 
...         if event_type == 'delta': 
...             cursors_list[int(device)].pos = vec2d(mouse.x, mouse.y)
The handler either hits a creep or moves a cursor.

Multi mouse Creeps!

No comments:

Post a Comment