source: nagslang/game_object.py @ 520:3f79a77ef1e3

Last change on this file since 520:3f79a77ef1e3 was 520:3f79a77ef1e3, checked in by Stefano Rivera <stefano@…>, 7 years ago

Ephemeral messages

File size: 24.3 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):
305        super(EphemeralNote, self).__init__(
306            None,
307            render.NullRenderer(),
308            puzzle.YesPuzzler(),
309            render.TextOverlay(message),
310        )
311        self.add_timer('timeout', timeout)
312        self.start_timer('timeout')
313
314    def update(self, dt):
315        super(EphemeralNote, self).update(dt)
316        if not self.check_timer('timeout'):
317            return Result(remove=[self])
318
319
320class FloorLight(GameObject):
321    zorder = ZORDER_FLOOR
322
323    def __init__(self, space, position, state_source):
324        body = make_body(None, None, position)
325        self.shape = pymunk.Circle(body, 10)
326        self.shape.collision_type = COLLISION_TYPE_SWITCH
327        self.shape.sensor = True
328        super(FloorLight, self).__init__(
329            SingleShapePhysicser(space, self.shape),
330            render.ImageStateRenderer({
331                True: resources.get_image('objects', 'light_on.png'),
332                False: resources.get_image('objects', 'light_off.png'),
333            }),
334            puzzle.StateProxyPuzzler(state_source),
335        )
336
337    @classmethod
338    def requires(cls):
339        return [("name", "string"), ("position", "coordinates"),
340                ("state_source", "puzzler")]
341
342
343class Box(GameObject):
344    def __init__(self, space, position):
345        body = make_body(10, 10000, position, damping=0.5)
346        self.shape = pymunk.Poly(
347            body, [(-20, -20), (20, -20), (20, 20), (-20, 20)])
348        self.shape.friction = 0.5
349        self.shape.collision_type = COLLISION_TYPE_FURNITURE
350        super(Box, self).__init__(
351            SingleShapePhysicser(space, self.shape),
352            render.ImageRenderer(resources.get_image('objects', 'crate.png')),
353        )
354
355    @classmethod
356    def requires(cls):
357        return [("name", "string"), ("position", "coordinates"),
358                ("state_source", "puzzler")]
359
360
361class BaseDoor(GameObject):
362    zorder = ZORDER_FLOOR
363    is_open = True
364
365    def __init__(self, space, position, destination, dest_pos, angle,
366                 renderer, condition):
367        body = make_body(pymunk.inf, pymunk.inf, position, damping=0.5)
368        self.shape = pymunk.Circle(body, 30)
369        self.shape.collision_type = COLLISION_TYPE_DOOR
370        self.shape.body.angle = float(angle) / 180 * math.pi
371        self.shape.sensor = True
372        self.destination = destination
373        self.dest_pos = tuple(dest_pos)
374        super(BaseDoor, self).__init__(
375            SingleShapePhysicser(space, self.shape),
376            renderer,
377            puzzle.ParentAttrPuzzler('is_open'),
378            interactible=environment.Interactible(
379                environment.Action(self._post_door_event, condition)),
380        )
381
382    def _post_door_event(self, protagonist):
383        DoorEvent.post(self.destination, self.dest_pos)
384
385
386class Door(BaseDoor):
387    def __init__(self, space, position, destination, dest_pos, angle):
388        super(Door, self).__init__(
389            space, position, destination, dest_pos, angle,
390            render.ImageRenderer(resources.get_image('objects', 'door.png')),
391            environment.YesCondition(),
392        )
393
394    @classmethod
395    def requires(cls):
396        return [("name", "string"), ("position", "coordinates"),
397                ("destination", "level name"), ("dest_pos", "coordinate"),
398                ("angle", "degrees")]
399
400
401class RestartGameDoor(Door):
402    def _post_door_event(self, protagonist):
403        protagonist.world.reset()
404        super(RestartGameDoor, self)._post_door_event(protagonist)
405
406
407class ContinueGameDoor(Door):
408    def _post_door_event(self, protagonist):
409        world = protagonist.world
410        if world.level[0]:
411            DoorEvent.post(world.level[0], world.level[1])
412        else:
413            # New game?
414            super(ContinueGameDoor, self)._post_door_event(protagonist)
415
416
417def make_overlay_image(image_name, angle):
418    transforms = ()
419    if angle != 0:
420        transforms = (rotator(-angle),)
421    return resources.get_image('objects', image_name, transforms=transforms)
422
423
424class PuzzleDoor(BaseDoor):
425    def __init__(self, space, position, destination, dest_pos, angle,
426                 key_state):
427        self._key_state = key_state
428        overlay = ImageOverlay(make_overlay_image('lock.png', angle))
429        super(PuzzleDoor, self).__init__(
430            space, position, destination, dest_pos, angle,
431            render.ImageStateRenderer({
432                True: resources.get_image('objects', 'door.png'),
433                False: resources.get_image(
434                    'objects', 'door.png', transforms=(overlay,)),
435            }),
436            environment.FunctionCondition(lambda p: self.is_open),
437        )
438
439    @property
440    def is_open(self):
441        return self._stored_state['is_open']
442
443    def set_stored_state_dict(self, stored_state):
444        self._stored_state = stored_state
445        self._stored_state.setdefault('is_open', False)
446        return True
447
448    def update(self, dt):
449        if not self.is_open:
450            self._stored_state['is_open'] = self.puzzler.glue.get_state_of(
451                self._key_state)
452        super(PuzzleDoor, self).update(dt)
453
454    @classmethod
455    def requires(cls):
456        return [("name", "string"), ("position", "coordinates"),
457                ("destination", "level name"), ("dest_pos", "coordinate"),
458                ("angle", "degrees"),
459                ("key_state", "puzzler")]
460
461
462class KeyedDoor(BaseDoor):
463    def __init__(self, space, position, destination, dest_pos, angle,
464                 key_item=None):
465        self._key_item = key_item
466        overlay = ImageOverlay(
467            make_overlay_image('%s.png' % (key_item,), angle))
468        super(KeyedDoor, self).__init__(
469            space, position, destination, dest_pos, angle,
470            render.ImageRenderer(resources.get_image(
471                'objects', 'door.png', transforms=(overlay,))),
472            environment.ItemRequiredCondition(key_item),
473        )
474
475    @classmethod
476    def requires(cls):
477        return [("name", "string"), ("position", "coordinates"),
478                ("destination", "level name"), ("dest_pos", "coordinate"),
479                ("angle", "degrees"), ("key_item", "item name")]
480
481
482class Bulkhead(GameObject):
483    zorder = ZORDER_FLOOR
484
485    def __init__(self, space, end1, end2, key_state=None):
486        body = make_body(None, None, (0, 0))
487        self.shape = pymunk.Segment(body, tuple(end1), tuple(end2), 7)
488        self.shape.collision_type = COLLISION_TYPE_DOOR
489        if key_state is None:
490            puzzler = puzzle.YesPuzzler()
491        else:
492            puzzler = puzzle.StateProxyPuzzler(key_state)
493        super(Bulkhead, self).__init__(
494            SingleShapePhysicser(space, self.shape),
495            render.BulkheadRenderer(),
496            puzzler,
497        )
498
499    def collide_with_protagonist(self, protagonist):
500        if self.puzzler.get_state():
501            # Reject the collision, we can walk through.
502            return False
503        return True
504
505    collide_with_furniture = collide_with_protagonist
506
507    @classmethod
508    def requires(cls):
509        return [("name", "string"), ("end1", "coordinates"),
510                ("end2", "coordinates"), ("key_state", "puzzler")]
511
512
513class ToggleSwitch(GameObject):
514    zorder = ZORDER_LOW
515
516    def __init__(self, space, position):
517        body = make_body(None, None, position)
518        self.shape = pymunk.Circle(body, 20)
519        self.shape.sensor = True
520        super(ToggleSwitch, self).__init__(
521            SingleShapePhysicser(space, self.shape),
522            render.ImageStateRenderer({
523                True: resources.get_image('objects', 'lever.png'),
524                False: resources.get_image(
525                    'objects', 'lever.png', transforms=(FLIP_H,)),
526            }),
527            puzzle.ParentAttrPuzzler('toggle_on'),
528            interactible=environment.Interactible(
529                environment.Action(self._toggle)),
530        )
531
532    @property
533    def toggle_on(self):
534        return self._stored_state['toggle_on']
535
536    def _toggle(self, protagonist):
537        self._stored_state['toggle_on'] = not self.toggle_on
538
539    def set_stored_state_dict(self, stored_state):
540        self._stored_state = stored_state
541        # We start in the "off" position.
542        self._stored_state.setdefault('toggle_on', False)
543        return True
544
545    @classmethod
546    def requires(cls):
547        return [("name", "string"), ("position", "coordinates")]
548
549
550class Bullet(GameObject):
551    def __init__(self, space, position, impulse, damage, bullet_type,
552                 source_collision_type):
553        body = make_body(1, pymunk.inf, position)
554        body.angle = impulse.angle
555        self.last_position = position
556        self.shape = pymunk.Circle(body, 2)
557        self.shape.sensor = True
558        self.shape.collision_type = COLLISION_TYPE_PROJECTILE
559        self.damage = damage
560        self.type = bullet_type
561        self.source_collision_type = source_collision_type
562        super(Bullet, self).__init__(
563            SingleShapePhysicser(space, self.shape),
564            render.ImageRenderer(resources.get_image(
565                'objects', '%s.png' % self.type)),
566        )
567        self.physicser.apply_impulse(impulse)
568
569    def update(self, dt):
570        super(Bullet, self).update(dt)
571        position = (self.physicser.position.x, self.physicser.position.y)
572        r = self.get_space().segment_query(self.last_position, position)
573        self.last_position = position
574        for collision in r:
575            shape = collision.shape
576            if (shape.collision_type == self.source_collision_type
577                    or shape == self.physicser.get_shape()
578                    or shape.sensor):
579                continue
580            if hasattr(shape, 'physicser'):
581                shape.physicser.game_object.hit(self)
582            self.physicser.remove_from_space()
583            return Result(remove=[self])
584
585
586class ClawAttack(GameObject):
587    def __init__(self, space, pos, vector, damage):
588        body = make_body(1, pymunk.inf,
589                         (pos[0] + (vector.length * math.cos(vector.angle)),
590                          pos[1] + (vector.length * math.sin(vector.angle))))
591        body.angle = vector.angle
592        self.shape = pymunk.Circle(body, 30)
593        self.shape.sensor = True
594        self.shape.collision_type = COLLISION_TYPE_WEREWOLF_ATTACK
595        self.damage = damage
596        super(ClawAttack, self).__init__(
597            SingleShapePhysicser(space, self.shape),
598            render.ImageRenderer(resources.get_image(
599                'objects', 'werewolf_SW_claw_attack.png',
600                transforms=(FLIP_H,))),
601        )
602
603    def update(self, dt):
604        super(ClawAttack, self).update(dt)
605        if self.lifetime > 0.1:
606            self.physicser.remove_from_space()
607            return Result(remove=[self])
608
609
610class HostileTerrain(GameObject):
611    zorder = ZORDER_FLOOR
612    damage = None
613    tiles = []
614    tile_alpha = 255
615    tile_frame_ticks = 3
616    # How often to hit the player
617    rate = 5
618
619    def __init__(self, space, position, outline):
620        body = make_body(10, pymunk.inf, position)
621        # Adjust shape relative to position
622        shape_outline = [(p[0] - position[0], p[1] - position[1]) for
623                         p in outline]
624        self.shape = pymunk.Poly(body, shape_outline)
625        self._ticks = 0
626        self.shape.collision_type = COLLISION_TYPE_SWITCH
627        self.shape.sensor = True
628        if len(self.tiles) > 1:
629            tile_images = [resources.get_image('tiles', x)
630                           for x in self.tiles]
631            renderer = render.TimedTiledRenderer(outline, tile_images,
632                                                 self.tile_frame_ticks,
633                                                 self.tile_alpha)
634        else:
635            tile_image = resources.get_image('tiles', self.tiles[0])
636            renderer = render.TiledRenderer(outline, tile_image,
637                                            self.tile_alpha)
638        super(HostileTerrain, self).__init__(
639            SingleShapePhysicser(space, self.shape),
640            renderer)
641
642    def collide_with_protagonist(self, protagonist):
643        # We're called every frame we're colliding, so
644        # There are timing issues with stepping on and
645        # off terrian, but as long as the rate is reasonably
646        # low, they shouldn't impact gameplay
647        if self._ticks == 0:
648            self.apply_effect(protagonist)
649        self._ticks += 1
650        if self._ticks > self.rate:
651            self._ticks = 0
652
653    def apply_effect(self, protagonist):
654        protagonist.lose_health(self.damage)
655
656    @classmethod
657    def requires(cls):
658        return [("name", "string"), ("position", "coordinates"),
659                ("outline", "polygon (convex)")]
660
661
662class AcidFloor(HostileTerrain):
663    damage = 1
664    tiles = ['acid.png', 'acid2.png', 'acid3.png']
665    tile_alpha = 200
666    tile_frame_ticks = 10
667
668
669class ForceWolfFloor(HostileTerrain):
670    tiles = ['moonlight.png']
671    rate = 0
672    tile_alpha = 150
673    zorder = ZORDER_HIGH
674
675    def apply_effect(self, protagonist):
676        protagonist.force_wolf_form()
677
678
679class GravityWell(GameObject):
680    zorder = ZORDER_FLOOR
681    # How often to hit the player
682    rate = 5
683
684    def __init__(self, space, position, radius, force):
685        body = make_body(None, None, position)
686        # Adjust shape relative to position
687        self._radius = radius
688        self.shape = pymunk.Circle(body, radius)
689        self.centre = pymunk.Circle(body, 10)
690        self.centre.friction = pymunk.inf
691        self._ticks = 0
692        self.force = force
693        self.shape.collision_type = COLLISION_TYPE_SWITCH
694        self.shape.sensor = True
695        super(GravityWell, self).__init__(
696            MultiShapePhysicser(space, self.shape, self.centre),
697            render.ImageRenderer(resources.get_image(
698                'objects', 'gravity_well.png')),
699        )
700
701    def collide_with_protagonist(self, protagonist):
702        # We're called every frame we're colliding, so
703        # There are timing issues with stepping on and
704        # off terrian, but as long as the rate is reasonably
705        # low, they shouldn't impact gameplay
706        self.apply_effect(protagonist)
707
708    def collide_with_furniture(self, furniture):
709        # We're called every frame we're colliding, so
710        # There are timing issues with stepping on and
711        # off terrian, but as long as the rate is reasonably
712        # low, they shouldn't impact gameplay
713        self.apply_effect(furniture)
714
715    def apply_effect(self, object_to_move):
716        movement = self.physicser.position - object_to_move.physicser.position
717        local_force = self.force * (1 - (self._radius / movement.length))
718        movement.length = local_force
719        object_to_move.environmental_movement(-movement)
720
721    @classmethod
722    def requires(cls):
723        return [("name", "string"), ("position", "coordinates"),
724                ("outline", "polygon (convex)")]
725
726
727class SheepPen(GameObject):
728    zorder = ZORDER_FLOOR
729
730    def __init__(self, space, position, outline, sheep_count):
731        body = make_body(None, None, position)
732        # Adjust shape relative to position
733        shape_outline = [(p[0] - position[0], p[1] - position[1]) for
734                         p in outline]
735        self.shape = pymunk.Poly(body, shape_outline)
736        self.shape.collision_type = COLLISION_TYPE_SHEEP_PEN
737        self.shape.sensor = True
738        super(SheepPen, self).__init__(
739            SingleShapePhysicser(space, self.shape),
740            render.Renderer(),
741            puzzle.MultiCollidePuzzler(sheep_count, COLLISION_TYPE_SHEEP),
742        )
743
744    @classmethod
745    def requires(cls):
746        return [("name", "string"), ("position", "coordinates"),
747                ("outline", "polygon (convex)"), ("sheep_count", "int")]
Note: See TracBrowser for help on using the repository browser.