source: nagslang/game_object.py @ 548:b0c5f032eb9d

Last change on this file since 548:b0c5f032eb9d was 548:b0c5f032eb9d, checked in by Neil Muller <drnlmuller@…>, 7 years ago

Tweak terrain render creation logic

File size: 24.6 KB
Line 
1import pymunk
2import pymunk.pygame_util
3
4import math
5
6from nagslang import environment
7from nagslang import puzzle
8from nagslang import render
9from nagslang.mutators import FLIP_H, ImageOverlay, rotator
10from nagslang.constants import (
11    COLLISION_TYPE_DOOR, COLLISION_TYPE_FURNITURE, COLLISION_TYPE_PROJECTILE,
12    COLLISION_TYPE_SWITCH, COLLISION_TYPE_SHEEP, COLLISION_TYPE_SHEEP_PEN,
13    COLLISION_TYPE_WEREWOLF_ATTACK, SWITCH_PUSHERS, ZORDER_FLOOR, ZORDER_LOW,
14    ZORDER_HIGH)
15from nagslang.resources import resources
16from nagslang.events import DoorEvent
17
18
19class Result(object):
20    '''
21    Return from an update() function, to add new objects to the world, and/or
22    remove old objects.
23    '''
24    def __init__(self, add=(), remove=()):
25        self.add = add
26        self.remove = remove
27
28    def merge(self, result):
29        if result is not None:
30            self.add += result.add
31            self.remove += result.remove
32        return self
33
34
35def get_editable_game_objects():
36    classes = []
37    for cls_name, cls in globals().iteritems():
38        if isinstance(cls, type) and hasattr(cls, 'requires'):
39            classes.append((cls_name, cls))
40    return classes
41
42
43class Physicser(object):
44    def __init__(self, space):
45        self._space = space
46
47    def get_space(self):
48        return self._space
49
50    def set_space(self, new_space):
51        self._space = new_space
52
53    def set_game_object(self, game_object):
54        self.game_object = game_object
55
56    def get_shape(self):
57        raise NotImplementedError()
58
59    def add_to_space(self):
60        shape = self.get_shape()
61        self.get_space().add(shape)
62        if not shape.body.is_static:
63            self.get_space().add(shape.body)
64
65    def remove_from_space(self):
66        shape = self.get_shape()
67        self.get_space().remove(shape)
68        if not shape.body.is_static:
69            self.get_space().remove(shape.body)
70
71    def get_render_position(self, surface):
72        pos = self.get_shape().body.position
73        return pymunk.pygame_util.to_pygame(pos, surface)
74
75    def get_angle(self):
76        return self.get_shape().body.angle
77
78    def get_velocity(self):
79        return self.get_shape().body.velocity
80
81    def _get_position(self):
82        return self.get_shape().body.position
83
84    def _set_position(self, position):
85        self.get_shape().body.position = position
86
87    position = property(_get_position, _set_position)
88
89    def apply_impulse(self, j, r=(0, 0)):
90        return self.get_shape().body.apply_impulse(j, r)
91
92
93class SingleShapePhysicser(Physicser):
94    def __init__(self, space, shape):
95        super(SingleShapePhysicser, self).__init__(space)
96        self._shape = shape
97        shape.physicser = self
98
99    def get_shape(self):
100        return self._shape
101
102
103class MultiShapePhysicser(Physicser):
104    def __init__(self, space, shape, *extra_shapes):
105        super(MultiShapePhysicser, self).__init__(space)
106        self._shape = shape
107        self._extra_shapes = extra_shapes
108        shape.physicser = self
109
110    def get_shape(self):
111        return self._shape
112
113    def add_to_space(self):
114        shape = self.get_shape()
115        self.get_space().add(shape)
116        if not shape.body.is_static:
117            self.get_space().add(shape.body)
118        for s in self._extra_shapes:
119            self.get_space().add(s)
120
121    def remove_from_space(self):
122        shape = self.get_shape()
123        self.get_space().remove(shape)
124        if not shape.body.is_static:
125            self.get_space().remove(shape.body)
126        for s in self._extra_shapes:
127            self.get_space().remove(s)
128
129
130def damping_velocity_func(body, gravity, damping, dt):
131    """Apply custom damping to this body's velocity.
132    """
133    damping = getattr(body, 'damping', damping)
134    return pymunk.Body.update_velocity(body, gravity, damping, dt)
135
136
137def make_body(mass, moment, position, damping=None):
138    body = pymunk.Body(mass, moment)
139    body.position = tuple(position)
140    if damping is not None:
141        body.damping = damping
142        body.velocity_func = damping_velocity_func
143    return body
144
145
146class GameObject(object):
147    """A representation of a thing in the game world.
148
149    This has a rendery thing, physicsy things and maybe some other things.
150    """
151
152    zorder = ZORDER_LOW
153    is_moving = False  # `True` if a movement animation should play.
154
155    def __init__(self, physicser, renderer, puzzler=None, overlay=None,
156                 interactible=None):
157        self.lifetime = 0
158        self.physicser = physicser
159        if physicser is not None:
160            physicser.set_game_object(self)
161            self.physicser.add_to_space()
162        self.renderer = renderer
163        renderer.set_game_object(self)
164        self.puzzler = puzzler
165        if puzzler is not None:
166            puzzler.set_game_object(self)
167        self.overlay = overlay
168        if overlay is not None:
169            self.overlay.set_game_object(self)
170        self.interactible = interactible
171        if interactible is not None:
172            self.interactible.set_game_object(self)
173        self._timers = {}
174        self._active_timers = {}
175
176    def add_timer(self, name, secs):
177        self._timers[name] = secs
178
179    def start_timer(self, name, secs=None):
180        if secs is None:
181            secs = self._timers[name]
182        self._active_timers[name] = secs
183
184    def check_timer(self, name):
185        return name in self._active_timers
186
187    def set_stored_state_dict(self, stored_state):
188        """Override this to set up whatever state storage you want.
189
190        The `stored_state` dict passed in contains whatever saved state we
191        might have for this object. If the return value of this method
192        evaluates to `True`, the contents of the `stored_state` dict will be
193        saved, otherwise it will be discarded.
194        """
195        pass
196
197    def get_space(self):
198        return self.physicser.get_space()
199
200    def get_shape(self):
201        return self.physicser.get_shape()
202
203    def get_render_position(self, surface):
204        return self.physicser.get_render_position(surface)
205
206    def get_render_angle(self):
207        return self.physicser.get_angle()
208
209    def get_facing_direction(self):
210        """Used by rendererd that care what direction an object is facing.
211        """
212        return None
213
214    def render(self, surface):
215        return self.renderer.render(surface)
216
217    def update(self, dt):
218        self.lifetime += dt
219        for timer in self._active_timers.keys():
220            self._active_timers[timer] -= dt
221            if self._active_timers[timer] <= 0:
222                self._active_timers.pop(timer)
223        self.renderer.update(dt)
224
225    def hit(self, weapon):
226        '''Was hit with a weapon (such as a bullet)'''
227        pass
228
229    def collide_with_protagonist(self, protagonist):
230        """Called as a `pre_solve` collision callback with the protagonist.
231
232        You can return `False` to ignore the collision, anything else
233        (including `None`) to process the collision as normal.
234        """
235        return True
236
237    def collide_with_furniture(self, furniture):
238        return True
239
240    def collide_with_claw_attack(self, claw_attack):
241        return True
242
243    def environmental_movement(self, vec):
244        self.physicser.apply_impulse(vec)
245
246    @classmethod
247    def requires(cls):
248        """Hints for the level editor"""
249        return [("name", "string")]
250
251    @classmethod
252    def movable(cls):
253        # Are we movable
254        hints = cls.requires()
255        for x in hints:
256            if 'position' in x:
257                return True
258        return False
259
260
261class FloorSwitch(GameObject):
262    zorder = ZORDER_FLOOR
263
264    def __init__(self, space, position):
265        body = make_body(None, None, position)
266        self.shape = pymunk.Circle(body, 30)
267        self.shape.collision_type = COLLISION_TYPE_SWITCH
268        self.shape.sensor = True
269        super(FloorSwitch, self).__init__(
270            SingleShapePhysicser(space, self.shape),
271            render.ImageStateRenderer({
272                True: resources.get_image('objects', 'sensor_on.png'),
273                False: resources.get_image('objects', 'sensor_off.png'),
274            }),
275            puzzle.CollidePuzzler(*SWITCH_PUSHERS),
276        )
277
278    @classmethod
279    def requires(cls):
280        return [("name", "string"), ("position", "coordinates")]
281
282
283class Note(GameObject):
284    zorder = ZORDER_FLOOR
285
286    def __init__(self, space, position, message):
287        body = make_body(None, None, position)
288        self.shape = pymunk.Circle(body, 30)
289        self.shape.sensor = True
290        super(Note, self).__init__(
291            SingleShapePhysicser(space, self.shape),
292            render.ImageRenderer(resources.get_image('objects', 'note.png')),
293            puzzle.CollidePuzzler(),
294            render.TextOverlay(message),
295        )
296
297    @classmethod
298    def requires(cls):
299        return [("name", "string"), ("position", "coordinates"),
300                ("message", "text")]
301
302
303class EphemeralNote(GameObject):
304    def __init__(self, message, timeout, **kwargs):
305        kwargs.setdefault('bg_colour', (255, 180, 180, 192))
306        super(EphemeralNote, self).__init__(
307            None,
308            render.NullRenderer(),
309            puzzle.YesPuzzler(),
310            render.TextOverlay(message, **kwargs),
311        )
312        self.add_timer('timeout', timeout)
313        self.start_timer('timeout')
314
315    def update(self, dt):
316        super(EphemeralNote, self).update(dt)
317        if not self.check_timer('timeout'):
318            return Result(remove=[self])
319
320
321class FloorLight(GameObject):
322    zorder = ZORDER_FLOOR
323
324    def __init__(self, space, position, state_source):
325        body = make_body(None, None, position)
326        self.shape = pymunk.Circle(body, 10)
327        self.shape.collision_type = COLLISION_TYPE_SWITCH
328        self.shape.sensor = True
329        super(FloorLight, self).__init__(
330            SingleShapePhysicser(space, self.shape),
331            render.ImageStateRenderer({
332                True: resources.get_image('objects', 'light_on.png'),
333                False: resources.get_image('objects', 'light_off.png'),
334            }),
335            puzzle.StateProxyPuzzler(state_source),
336        )
337
338    @classmethod
339    def requires(cls):
340        return [("name", "string"), ("position", "coordinates"),
341                ("state_source", "puzzler")]
342
343
344class Box(GameObject):
345    def __init__(self, space, position):
346        body = make_body(10, 10000, position, damping=0.5)
347        self.shape = pymunk.Poly(
348            body, [(-20, -20), (20, -20), (20, 20), (-20, 20)])
349        self.shape.friction = 0.5
350        self.shape.collision_type = COLLISION_TYPE_FURNITURE
351        super(Box, self).__init__(
352            SingleShapePhysicser(space, self.shape),
353            render.ImageRenderer(resources.get_image('objects', 'crate.png')),
354        )
355
356    @classmethod
357    def requires(cls):
358        return [("name", "string"), ("position", "coordinates"),
359                ("state_source", "puzzler")]
360
361
362class BaseDoor(GameObject):
363    zorder = ZORDER_FLOOR
364    is_open = True
365
366    def __init__(self, space, position, destination, dest_pos, angle,
367                 renderer, condition):
368        body = make_body(pymunk.inf, pymunk.inf, position, damping=0.5)
369        self.shape = pymunk.Circle(body, 30)
370        self.shape.collision_type = COLLISION_TYPE_DOOR
371        self.shape.body.angle = float(angle) / 180 * math.pi
372        self.shape.sensor = True
373        self.destination = destination
374        self.dest_pos = tuple(dest_pos)
375        super(BaseDoor, self).__init__(
376            SingleShapePhysicser(space, self.shape),
377            renderer,
378            puzzle.ParentAttrPuzzler('is_open'),
379            interactible=environment.Interactible(
380                environment.Action(self._post_door_event, condition)),
381        )
382
383    def _post_door_event(self, protagonist):
384        DoorEvent.post(self.destination, self.dest_pos)
385
386
387class Door(BaseDoor):
388    def __init__(self, space, position, destination, dest_pos, angle):
389        super(Door, self).__init__(
390            space, position, destination, dest_pos, angle,
391            render.ImageRenderer(resources.get_image('objects', 'door.png')),
392            environment.YesCondition(),
393        )
394
395    @classmethod
396    def requires(cls):
397        return [("name", "string"), ("position", "coordinates"),
398                ("destination", "level name"), ("dest_pos", "coordinate"),
399                ("angle", "degrees")]
400
401
402class RestartGameDoor(Door):
403    def _post_door_event(self, protagonist):
404        protagonist.world.reset()
405        super(RestartGameDoor, self)._post_door_event(protagonist)
406
407
408class ContinueGameDoor(Door):
409    def _post_door_event(self, protagonist):
410        world = protagonist.world
411        if world.level[0]:
412            DoorEvent.post(world.level[0], world.level[1])
413        else:
414            # New game?
415            super(ContinueGameDoor, self)._post_door_event(protagonist)
416
417
418def make_overlay_image(image_name, angle):
419    transforms = ()
420    if angle != 0:
421        transforms = (rotator(-angle),)
422    return resources.get_image('objects', image_name, transforms=transforms)
423
424
425class PuzzleDoor(BaseDoor):
426    def __init__(self, space, position, destination, dest_pos, angle,
427                 key_state):
428        self._key_state = key_state
429        overlay = ImageOverlay(make_overlay_image('lock.png', angle))
430        super(PuzzleDoor, self).__init__(
431            space, position, destination, dest_pos, angle,
432            render.ImageStateRenderer({
433                True: resources.get_image('objects', 'door.png'),
434                False: resources.get_image(
435                    'objects', 'door.png', transforms=(overlay,)),
436            }),
437            environment.FunctionCondition(lambda p: self.is_open),
438        )
439
440    @property
441    def is_open(self):
442        return self._stored_state['is_open']
443
444    def set_stored_state_dict(self, stored_state):
445        self._stored_state = stored_state
446        self._stored_state.setdefault('is_open', False)
447        return True
448
449    def update(self, dt):
450        if not self.is_open:
451            self._stored_state['is_open'] = self.puzzler.glue.get_state_of(
452                self._key_state)
453        super(PuzzleDoor, self).update(dt)
454
455    @classmethod
456    def requires(cls):
457        return [("name", "string"), ("position", "coordinates"),
458                ("destination", "level name"), ("dest_pos", "coordinate"),
459                ("angle", "degrees"),
460                ("key_state", "puzzler")]
461
462
463class KeyedDoor(BaseDoor):
464    def __init__(self, space, position, destination, dest_pos, angle,
465                 key_item=None):
466        self._key_item = key_item
467        overlay = ImageOverlay(
468            make_overlay_image('%s.png' % (key_item,), angle))
469        super(KeyedDoor, self).__init__(
470            space, position, destination, dest_pos, angle,
471            render.ImageRenderer(resources.get_image(
472                'objects', 'door.png', transforms=(overlay,))),
473            environment.ItemRequiredCondition(key_item),
474        )
475
476    @classmethod
477    def requires(cls):
478        return [("name", "string"), ("position", "coordinates"),
479                ("destination", "level name"), ("dest_pos", "coordinate"),
480                ("angle", "degrees"), ("key_item", "item name")]
481
482
483class Bulkhead(GameObject):
484    zorder = ZORDER_FLOOR
485
486    def __init__(self, space, end1, end2, key_state=None):
487        body = make_body(None, None, (0, 0))
488        self.shape = pymunk.Segment(body, tuple(end1), tuple(end2), 7)
489        self.shape.collision_type = COLLISION_TYPE_DOOR
490        if key_state is None:
491            puzzler = puzzle.YesPuzzler()
492        else:
493            puzzler = puzzle.StateProxyPuzzler(key_state)
494        super(Bulkhead, self).__init__(
495            SingleShapePhysicser(space, self.shape),
496            render.BulkheadRenderer(),
497            puzzler,
498        )
499
500    def collide_with_protagonist(self, protagonist):
501        if self.puzzler.get_state():
502            # Reject the collision, we can walk through.
503            return False
504        return True
505
506    collide_with_furniture = collide_with_protagonist
507
508    @classmethod
509    def requires(cls):
510        return [("name", "string"), ("end1", "coordinates"),
511                ("end2", "coordinates"), ("key_state", "puzzler")]
512
513
514class ToggleSwitch(GameObject):
515    zorder = ZORDER_LOW
516
517    def __init__(self, space, position):
518        body = make_body(None, None, position)
519        self.shape = pymunk.Circle(body, 20)
520        self.shape.sensor = True
521        super(ToggleSwitch, self).__init__(
522            SingleShapePhysicser(space, self.shape),
523            render.ImageStateRenderer({
524                True: resources.get_image('objects', 'lever.png'),
525                False: resources.get_image(
526                    'objects', 'lever.png', transforms=(FLIP_H,)),
527            }),
528            puzzle.ParentAttrPuzzler('toggle_on'),
529            interactible=environment.Interactible(
530                environment.Action(self._toggle)),
531        )
532
533    @property
534    def toggle_on(self):
535        return self._stored_state['toggle_on']
536
537    def _toggle(self, protagonist):
538        self._stored_state['toggle_on'] = not self.toggle_on
539
540    def set_stored_state_dict(self, stored_state):
541        self._stored_state = stored_state
542        # We start in the "off" position.
543        self._stored_state.setdefault('toggle_on', False)
544        return True
545
546    @classmethod
547    def requires(cls):
548        return [("name", "string"), ("position", "coordinates")]
549
550
551class Bullet(GameObject):
552    def __init__(self, space, position, impulse, damage, bullet_type,
553                 source_collision_type):
554        body = make_body(1, pymunk.inf, position)
555        body.angle = impulse.angle
556        self.last_position = position
557        self.shape = pymunk.Circle(body, 2)
558        self.shape.sensor = True
559        self.shape.collision_type = COLLISION_TYPE_PROJECTILE
560        self.damage = damage
561        self.type = bullet_type
562        self.source_collision_type = source_collision_type
563        super(Bullet, self).__init__(
564            SingleShapePhysicser(space, self.shape),
565            render.ImageRenderer(resources.get_image(
566                'objects', '%s.png' % self.type)),
567        )
568        self.physicser.apply_impulse(impulse)
569
570    def update(self, dt):
571        super(Bullet, self).update(dt)
572        position = (self.physicser.position.x, self.physicser.position.y)
573        r = self.get_space().segment_query(self.last_position, position)
574        self.last_position = position
575        for collision in r:
576            shape = collision.shape
577            if (shape.collision_type == self.source_collision_type
578                    or shape == self.physicser.get_shape()
579                    or shape.sensor):
580                continue
581            if hasattr(shape, 'physicser'):
582                shape.physicser.game_object.hit(self)
583            self.physicser.remove_from_space()
584            return Result(remove=[self])
585
586
587class ClawAttack(GameObject):
588    def __init__(self, space, pos, vector, damage):
589        body = make_body(1, pymunk.inf,
590                         (pos[0] + (vector.length * math.cos(vector.angle)),
591                          pos[1] + (vector.length * math.sin(vector.angle))))
592        body.angle = vector.angle
593        self.shape = pymunk.Circle(body, 30)
594        self.shape.sensor = True
595        self.shape.collision_type = COLLISION_TYPE_WEREWOLF_ATTACK
596        self.damage = damage
597        super(ClawAttack, self).__init__(
598            SingleShapePhysicser(space, self.shape),
599            render.ImageRenderer(resources.get_image(
600                'objects', 'werewolf_SW_claw_attack.png',
601                transforms=(FLIP_H,))),
602        )
603
604    def update(self, dt):
605        super(ClawAttack, self).update(dt)
606        if self.lifetime > 0.1:
607            self.physicser.remove_from_space()
608            return Result(remove=[self])
609
610
611class HostileTerrain(GameObject):
612    zorder = ZORDER_FLOOR
613    damage = None
614    tiles = []
615    tile_alpha = 255
616    tile_frame_ticks = 3
617    # How often to hit the player
618    rate = 5
619
620    def __init__(self, space, position, outline):
621        body = make_body(10, pymunk.inf, position)
622        # Adjust shape relative to position
623        shape_outline = [(p[0] - position[0], p[1] - position[1]) for
624                         p in outline]
625        self.shape = pymunk.Poly(body, shape_outline)
626        self._ticks = 0
627        self.shape.collision_type = COLLISION_TYPE_SWITCH
628        self.shape.sensor = True
629        renderer = self._fix_image(outline)
630        super(HostileTerrain, self).__init__(
631            SingleShapePhysicser(space, self.shape),
632            renderer)
633
634    def _fix_image(self, outline):
635        if len(self.tiles) > 1:
636            tile_images = [resources.get_image('tiles', x)
637                           for x in self.tiles]
638            renderer = render.TimedTiledRenderer(outline, tile_images,
639                                                 self.tile_frame_ticks,
640                                                 self.tile_alpha)
641        else:
642            tile_image = resources.get_image('tiles', self.tiles[0])
643            renderer = render.TiledRenderer(outline, tile_image,
644                                            self.tile_alpha)
645        return renderer
646
647    def update_image(self, new_outline):
648        self.renderer = self._fix_image(new_outline)
649
650    def collide_with_protagonist(self, protagonist):
651        # We're called every frame we're colliding, so
652        # There are timing issues with stepping on and
653        # off terrian, but as long as the rate is reasonably
654        # low, they shouldn't impact gameplay
655        if self._ticks == 0:
656            self.apply_effect(protagonist)
657        self._ticks += 1
658        if self._ticks > self.rate:
659            self._ticks = 0
660
661    def apply_effect(self, protagonist):
662        protagonist.lose_health(self.damage)
663
664    @classmethod
665    def requires(cls):
666        return [("name", "string"), ("position", "coordinates"),
667                ("outline", "polygon (convex)")]
668
669
670class AcidFloor(HostileTerrain):
671    damage = 1
672    tiles = ['acid.png', 'acid2.png', 'acid3.png']
673    tile_alpha = 200
674    tile_frame_ticks = 10
675
676
677class ForceWolfFloor(HostileTerrain):
678    tiles = ['moonlight.png']
679    rate = 0
680    tile_alpha = 150
681    zorder = ZORDER_HIGH
682
683    def apply_effect(self, protagonist):
684        protagonist.force_wolf_form()
685
686
687class GravityWell(GameObject):
688    zorder = ZORDER_FLOOR
689    # How often to hit the player
690    rate = 5
691
692    def __init__(self, space, position, radius, force):
693        body = make_body(None, None, position)
694        # Adjust shape relative to position
695        self._radius = radius
696        self.shape = pymunk.Circle(body, radius)
697        self.centre = pymunk.Circle(body, 10)
698        self.centre.friction = pymunk.inf
699        self._ticks = 0
700        self.force = force
701        self.shape.collision_type = COLLISION_TYPE_SWITCH
702        self.shape.sensor = True
703        super(GravityWell, self).__init__(
704            MultiShapePhysicser(space, self.shape, self.centre),
705            render.ImageRenderer(resources.get_image(
706                'objects', 'gravity_well.png')),
707        )
708
709    def collide_with_protagonist(self, protagonist):
710        # We're called every frame we're colliding, so
711        # There are timing issues with stepping on and
712        # off terrian, but as long as the rate is reasonably
713        # low, they shouldn't impact gameplay
714        self.apply_effect(protagonist)
715
716    def collide_with_furniture(self, furniture):
717        # We're called every frame we're colliding, so
718        # There are timing issues with stepping on and
719        # off terrian, but as long as the rate is reasonably
720        # low, they shouldn't impact gameplay
721        self.apply_effect(furniture)
722
723    def apply_effect(self, object_to_move):
724        movement = self.physicser.position - object_to_move.physicser.position
725        local_force = self.force * (1 - (self._radius / movement.length))
726        movement.length = local_force
727        object_to_move.environmental_movement(-movement)
728
729    @classmethod
730    def requires(cls):
731        return [("name", "string"), ("position", "coordinates"),
732                ("outline", "polygon (convex)")]
733
734
735class SheepPen(GameObject):
736    zorder = ZORDER_FLOOR
737
738    def __init__(self, space, position, outline, sheep_count):
739        body = make_body(None, None, position)
740        # Adjust shape relative to position
741        shape_outline = [(p[0] - position[0], p[1] - position[1]) for
742                         p in outline]
743        self.shape = pymunk.Poly(body, shape_outline)
744        self.shape.collision_type = COLLISION_TYPE_SHEEP_PEN
745        self.shape.sensor = True
746        super(SheepPen, self).__init__(
747            SingleShapePhysicser(space, self.shape),
748            render.Renderer(),
749            puzzle.MultiCollidePuzzler(sheep_count, COLLISION_TYPE_SHEEP),
750        )
751
752    @classmethod
753    def requires(cls):
754        return [("name", "string"), ("position", "coordinates"),
755                ("outline", "polygon (convex)"), ("sheep_count", "int")]
Note: See TracBrowser for help on using the repository browser.